I recently needed to add Google reviews to a client's Laravel site. The inspiration was one of those floating "Google Rating 4.9 ★★★★★" badges you see in the corner of business websites, plus a full "What our customers are saying" carousel section on the homepage — the kind most sites rent from third-party widget services for $10–30 a month.
Here's the thing: those widgets are just wrappers around data Google gives you directly. With the official Places API, a small service class, and one Blade partial, you can build the whole thing yourself — no subscription, no external script slowing your page down, and full control over the design.
In this tutorial I'll walk through everything I built: fetching the rating, review count, and latest reviews from the Places API (New), caching it so you're not hammering Google on every page load, and rendering a responsive review carousel with vanilla JS. Everything is copy-paste ready for a standard Laravel project.
Step 1 — Get an API key and your Place ID
You need two things from Google.
API key. In the Google Cloud Console, create a project (or use an existing one), then enable the Places API (New) — note the "(New)" part, because the legacy Places API uses different endpoints and this tutorial targets the new one. Create an API key under Credentials.
Restrict the key before you do anything else. Since we're calling the API from the server, restrict it by API (Places API New only). Don't use HTTP referrer restrictions — those are for browser-side keys, and our calls come from the backend.
Place ID. This identifies your business on Google Maps. The quickest way to find it is Google's own Place ID Finder. Search for the business and copy the ID — it looks something like ChIJh2DKfcO9MioRWiq....
Step 2 — Config
Add both to .env:
GOOGLE_PLACES_KEY=your_restricted_api_key
GOOGLE_PLACE_ID=your_place_id_hereAnd register them in config/services.php so you're never calling env() outside the config layer (which breaks under config:cache):
'google_places' => [
'key' => env('GOOGLE_PLACES_KEY'),
'place_id' => env('GOOGLE_PLACE_ID'),
],Step 3 — The service class
All the real work happens in one class: app/Services/GoogleReviewService.php. It fetches the place details, transforms the response into a clean array our views can consume, and caches the result for 24 hours.
<?php
namespace App\Services;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class GoogleReviewService
{
protected const CACHE_KEY = 'google_reviews_data';
public function get(): array
{
// Serve from cache whenever possible — one API call a day is plenty
$cached = Cache::get(self::CACHE_KEY);
if ($cached !== null) {
return $cached;
}
$key = config('services.google_places.key');
$placeId = config('services.google_places.place_id');
if (! $key || ! $placeId) {
return [];
}
try {
$response = Http::withHeaders([
'X-Goog-Api-Key' => $key,
// 'reviews' MUST be in the field mask or you get no review text
'X-Goog-FieldMask' => 'displayName,rating,userRatingCount,googleMapsUri,reviews',
])->get("https://places.googleapis.com/v1/places/{$placeId}", [
'languageCode' => 'en',
]);
if ($response->successful()) {
$data = $this->transform($response->json(), $placeId);
Cache::put(self::CACHE_KEY, $data, now()->addHours(24));
return $data;
}
Log::warning('Google Places failed', ['body' => $response->body()]);
} catch (\Throwable $e) {
Log::error('Google Places error: '.$e->getMessage());
}
// Cache the failure briefly so we don't hammer the API on every request
Cache::put(self::CACHE_KEY, [], now()->addMinutes(10));
return [];
}
protected function transform(array $data, string $placeId): array
{
return [
'name' => data_get($data, 'displayName.text', 'Google Rating'),
'rating' => (float) data_get($data, 'rating', 0),
'count' => (int) data_get($data, 'userRatingCount', 0),
'mapsUri' => data_get($data, 'googleMapsUri', 'https://www.google.com/maps/place/?q=place_id:'.$placeId),
'writeUri' => 'https://search.google.com/local/writereview?placeid='.$placeId,
'reviews' => collect(data_get($data, 'reviews', []))->map(fn ($r) => [
'author' => data_get($r, 'authorAttribution.displayName', 'Google user'),
'photo' => data_get($r, 'authorAttribution.photoUri'),
'uri' => data_get($r, 'authorAttribution.uri'),
'time' => data_get($r, 'relativePublishTimeDescription'),
'rating' => (int) data_get($r, 'rating', 5),
'text' => data_get($r, 'text.text') ?: data_get($r, 'originalText.text', ''),
])->filter(fn ($r) => $r['text'] !== '')->values()->all(),
];
}
}A few decisions in here worth explaining.
The field mask is not optional. The new Places API requires an X-Goog-FieldMask header telling it exactly which fields you want. If you forget reviews in that list, the API happily returns a 200 with rating and count but zero review text, and you'll spend an hour wondering why your carousel is empty. Ask me how I know.
Failures are cached too. If the API call fails (bad key, quota, network hiccup), I cache an empty array for 10 minutes. Without this, every single page view retries the failing call, which slows your site down and burns quota. With it, a failure costs you one API attempt per 10 minutes, and the views simply render nothing.
The transform layer keeps Blade clean. The raw API response is deeply nested (authorAttribution.displayName, text.text vs originalText.text, and so on). Flattening it once in the service means the Blade templates never touch API internals, and if Google changes the response shape, there's exactly one place to fix.
Reviews without text get filtered out. Plenty of people leave a star rating with no comment. A card showing a name and five stars but no words looks broken, so I drop those.
Step 4 — Feed the data to your views
Rather than passing $googleReview from every controller, use a view composer in app/Providers/AppServiceProvider.php:
use App\Services\GoogleReviewService;
use Illuminate\Support\Facades\View;
public function boot(): void
{
View::composer(
['partials.google-review', 'partials.google-reviews-section'],
function ($view) {
$view->with('googleReview', app(GoogleReviewService::class)->get());
}
);
}Here's a gotcha that cost me real debugging time: attach the composer to the partials, not the layout. My first attempt bound it to the layout view, thinking every child page would inherit the variable. But in Blade, a child view's @section blocks are rendered before the layout's composer runs — so a partial included inside a page section never received the data. Binding the composer directly to each partial means the data arrives no matter where the partial is included: layout, page, wherever.
Step 5 — The floating badge
This is the small always-visible badge, included once in your main layout (I put it right before the footer). Create resources/views/partials/google-review.blade.php:
@php $gr = $googleReview ?? []; @endphp
@if(!empty($gr['rating']))
<a href="{{ $gr['mapsUri'] }}" target="_blank" rel="noopener" class="gfr-badge">
<svg viewBox="0 0 48 48" width="34" height="34" aria-hidden="true"><path fill="#EA4335" d="M24 9.5c3.54 0 6.71 1.22 9.21 3.6l6.85-6.85C35.9 2.38 30.47 0 24 0 14.62 0 6.51 5.38 2.56 13.22l7.98 6.19C12.43 13.72 17.74 9.5 24 9.5z"/><path fill="#4285F4" d="M46.98 24.55c0-1.57-.15-3.09-.38-4.55H24v9.02h12.94c-.58 2.96-2.26 5.48-4.78 7.18l7.73 6c4.51-4.18 7.09-10.36 7.09-17.65z"/><path fill="#FBBC05" d="M10.53 28.59c-.48-1.45-.76-2.99-.76-4.59s.27-3.14.76-4.59l-7.98-6.19C.92 16.46 0 20.12 0 24c0 3.88.92 7.54 2.56 10.78l7.97-6.19z"/><path fill="#34A853" d="M24 48c6.48 0 11.93-2.13 15.89-5.81l-7.73-6c-2.15 1.45-4.92 2.3-8.16 2.3-6.26 0-11.57-4.22-13.47-9.91l-7.98 6.19C6.51 42.62 14.62 48 24 48z"/></svg>
<span class="gfr-badge-text">
<strong>Google Rating</strong>
<span class="gfr-badge-score">{{ number_format($gr['rating'], 1) }}
<span class="gfr-badge-stars" style="--fill: {{ min(100, ($gr['rating'] / 5) * 100) }}%">★★★★★<i>★★★★★</i></span>
</span>
<small>Based on {{ number_format($gr['count']) }} reviews</small>
</span>
</a>
<style>
.gfr-badge {
position: fixed; left: 16px; bottom: 16px; z-index: 999;
display: flex; align-items: center; gap: 10px;
background: #fff; padding: 10px 16px 10px 12px;
border-radius: 8px; border-top: 3px solid #34A853;
box-shadow: 0 4px 16px rgba(0,0,0,.18);
text-decoration: none !important; color: inherit;
}
.gfr-badge-text { display: flex; flex-direction: column; line-height: 1.35; }
.gfr-badge-text strong { font-size: 13px; color: #222; }
.gfr-badge-score { display: flex; align-items: center; gap: 6px; font-size: 16px; font-weight: 800; color: #e37400; }
.gfr-badge-stars { position: relative; font-size: 14px; color: #d8d8d8; letter-spacing: 1px; }
.gfr-badge-stars i { position: absolute; left: 0; top: 0; width: var(--fill, 100%); overflow: hidden; white-space: nowrap; color: #f5a623; font-style: normal; }
.gfr-badge small { font-size: 11px; color: #777; }
@media (max-width: 480px) { .gfr-badge { transform: scale(.9); transform-origin: bottom left; } }
</style>
@endifThe star fill trick is worth stealing for other projects: two overlapping rows of ★ characters, the gray row underneath and an orange row on top clipped to a percentage width via a CSS variable. A 4.7 rating fills the stars to exactly 94% — no star images, no SVG sprite sheets, no half-star icon fonts.
Step 6 — The "What our customers are saying" section
This is the main event: a homepage section with a summary block on the left and a review carousel on the right. Create resources/views/partials/google-reviews-section.blade.php.
The full file is long, so here's the structure first, then the interesting parts. It's one self-contained partial: markup, scoped CSS (everything prefixed grs- to avoid colliding with your theme), and a small vanilla JS slider — no carousel library needed.
@php $gr = $googleReview ?? []; @endphp
@if(!empty($gr['rating']) && !empty($gr['reviews']))
@php
$palette = ['#1a73e8','#d93025','#188038','#e37400','#8430ce','#00897b'];
@endphp
<section class="grs-section" id="google-reviews">
<div class="grs-container">
<div class="grs-heading">
<span class="grs-eyebrow">Google Reviews</span>
<h2 class="grs-title">What our <span>customers</span> are saying</h2>
</div>
<div class="grs-body">
{{-- Summary block: business name, rating, count, action buttons --}}
<div class="grs-summary">
<p class="grs-word">Excellent</p>
<p class="grs-name">{{ $gr['name'] }}</p>
<div class="grs-score">
<span class="grs-score-num">{{ number_format($gr['rating'], 1) }}</span>
<span class="grs-stars" style="--fill: {{ min(100, ($gr['rating'] / 5) * 100) }}%">★★★★★<i>★★★★★</i></span>
</div>
<p class="grs-count">Based on {{ number_format($gr['count']) }} reviews</p>
<div class="grs-actions">
<a class="grs-btn" href="{{ $gr['mapsUri'] }}" target="_blank" rel="noopener">See all reviews</a>
<a class="grs-btn grs-btn-alt" href="{{ $gr['writeUri'] }}" target="_blank" rel="noopener">Review us on Google</a>
</div>
</div>
{{-- Carousel --}}
<div class="grs-carousel">
<button class="grs-nav grs-prev" type="button" aria-label="Previous reviews">‹</button>
<div class="grs-viewport">
<div class="grs-track">
@foreach($gr['reviews'] as $i => $review)
<article class="grs-card">
<header class="grs-card-head">
@if(!empty($review['photo']))
<img class="grs-avatar" src="{{ $review['photo'] }}" alt=""
loading="lazy" referrerpolicy="no-referrer">
@else
<span class="grs-avatar grs-avatar-fallback"
style="background: {{ $palette[$i % count($palette)] }}">
{{ strtoupper(mb_substr($review['author'], 0, 1)) }}
</span>
@endif
<div class="grs-card-meta">
<a class="grs-author" href="{{ $review['uri'] }}" target="_blank" rel="noopener">{{ $review['author'] }}</a>
<span class="grs-time">{{ $review['time'] }}</span>
</div>
</header>
<div class="grs-card-stars" aria-label="{{ $review['rating'] }} out of 5 stars">
@for($s = 1; $s <= 5; $s++)
<span class="{{ $s <= $review['rating'] ? 'on' : '' }}">★</span>
@endfor
</div>
<p class="grs-text">{{ $review['text'] }}</p>
<a class="grs-more" href="{{ $gr['mapsUri'] }}" target="_blank" rel="noopener">Read more</a>
</article>
@endforeach
</div>
</div>
<button class="grs-nav grs-next" type="button" aria-label="Next reviews">›</button>
<div class="grs-dots"></div>
</div>
</div>
</div>
</section>For the CSS, the key ideas: the track is a flexbox row, each card gets flex: 0 0 calc(100% / var(--grs-per-view)), and sliding happens by translating the track. Text is clamped to five lines with -webkit-line-clamp so cards stay even-height regardless of how long-winded a reviewer was:
.grs-track { display: flex; transition: transform .45s ease; }
.grs-card { flex: 0 0 calc(100% / var(--grs-per-view, 3)); padding: 0 10px; box-sizing: border-box; }
.grs-text {
display: -webkit-box;
-webkit-line-clamp: 5;
-webkit-box-orient: vertical;
overflow: hidden;
}And the slider itself — the entire "carousel library" is about 50 lines of vanilla JS. It shows 3 cards on desktop, 2 on tablet, 1 on mobile, and rebuilds the dots on resize:
(function () {
function init() {
var carousel = document.querySelector('#google-reviews .grs-carousel');
if (!carousel) return;
var track = carousel.querySelector('.grs-track');
var cards = track.children.length;
var prevBtn = carousel.querySelector('.grs-prev');
var nextBtn = carousel.querySelector('.grs-next');
var dotsBox = carousel.querySelector('.grs-dots');
var index = 0;
function perView() {
var w = window.innerWidth;
if (w < 640) return 1;
if (w < 992) return 2;
return 3;
}
function pages() { return Math.max(1, cards - perView() + 1); }
function render() {
var pv = perView();
carousel.style.setProperty('--grs-per-view', pv);
index = Math.min(index, pages() - 1);
track.style.transform = 'translateX(-' + (index * (100 / pv)) + '%)';
prevBtn.disabled = index === 0;
nextBtn.disabled = index >= pages() - 1;
dotsBox.innerHTML = '';
for (var i = 0; i < pages(); i++) {
(function (n) {
var dot = document.createElement('button');
dot.type = 'button';
if (n === index) dot.className = 'active';
dot.addEventListener('click', function () { index = n; render(); });
dotsBox.appendChild(dot);
})(i);
}
}
prevBtn.addEventListener('click', function () { if (index > 0) { index--; render(); } });
nextBtn.addEventListener('click', function () { if (index < pages() - 1) { index++; render(); } });
window.addEventListener('resize', render);
// Auto-play, paused on hover
var auto = setInterval(step, 6000);
function step() { index = (index + 1) % pages(); render(); }
carousel.addEventListener('mouseenter', function () { clearInterval(auto); });
carousel.addEventListener('mouseleave', function () { auto = setInterval(step, 6000); });
render();
}
document.readyState === 'loading'
? document.addEventListener('DOMContentLoaded', init)
: init();
})();Two small details that matter more than they look. Reviewer photos come from googleusercontent.com and sometimes refuse to load with a referrer attached, so referrerpolicy="no-referrer" on the <img> fixes randomly-broken avatars. And when a reviewer has no photo at all, the fallback is a colored circle with their initial — the $palette array cycles through Google-ish colors so a row of fallback avatars doesn't look like a row of identical gray blobs.
Step 7 — Include it and clear caches
Drop the section wherever you want it on the homepage:
@include('partials.google-reviews-section')The floating badge goes once in your main layout, outside the page content:
@include('partials.google-review')Then:
php artisan cache:clear
php artisan config:clear
php artisan view:clearThe cache:clear matters here because your review data lives in the application cache — if you change the field mask or the transform and wonder why nothing updated, it's because you're still being served yesterday's cached payload.




