spatie/laravel-analytics
Retrieve data from Google Analytics in your Laravel app with spatie/laravel-analytics. Simple methods like fetchMostVisitedPages and fetchVisitorsAndPageViews return Laravel collections, with easy Composer installation and publishable config.
Three steps and you have data on screen:
composer require spatie/laravel-analytics
php artisan vendor:publish --tag="analytics-config"
storage_path('app/analytics/service-account-credentials.json'). Create the directory; the package won't do it for you.client_email from the JSON as a Viewer/Analyst. This is the step people skip and then debug a 403 for an hour.ANALYTICS_PROPERTY_ID is the numeric property ID (Admin → Property Settings), not the G-XXXXXXX measurement ID.First use case — a dashboard widget:
use Spatie\Analytics\Facades\Analytics;
use Spatie\Analytics\Period;
$topPages = Analytics::fetchMostVisitedPages(Period::days(7), 10);
// each item: ['fullPageUrl' => ..., 'pageTitle' => ..., 'screenPageViews' => ...]
Where to look first: config/analytics.php (property id, cache TTL, cache store), the facade's method list in the README, and — importantly — the GA4 Data API schema for valid metric/dimension names. The package is a thin wrapper; your real reference is Google's schema, not the package README.
Wrap the facade, don't sprinkle it. The facade binds a single configured property, so every consumer is coupled to global config. Put analytics behind your own class:
class AnalyticsDashboard
{
public function summary(Period $period): array
{
return [
'pages' => Analytics::fetchMostVisitedPages($period, 10),
'referrers' => Analytics::fetchTopReferrers($period, 10),
'browsers' => Analytics::fetchTopBrowsers($period, 5),
];
}
}
Then inject/type-hint it in controllers, Livewire components, or a view composer. This gives you one place to add caching, error handling, and test doubles.
Use get() as the escape hatch. The named methods cover maybe 20% of real reporting needs. Everything else is get() with GA4 metric/dimension names:
Analytics::get(
period: Period::create(now()->subDays(30), now()),
metrics: ['activeUsers', 'sessions', 'conversions'],
dimensions: ['sessionDefaultChannelGroup'],
maxResults: 50,
orderBy: [OrderBy::metric('sessions', true)],
);
Write small named helpers (conversionsByChannel(), signupsByLandingPage()) around get() so call sites stay readable and the metric strings live in one file.
Layer caching deliberately. The package caches whole API responses for cache_lifetime_in_minutes (default 1440). For dashboard tiles rendered on every request, that's usually enough. For anything heavier — cohort tables, 12-month trends, per-tenant reports — cache your derived result on top with a longer TTL, or persist to an analytics_snapshots table.
Snapshot to the database on a schedule. This is the single highest-leverage pattern: a nightly command writes yesterday's numbers into your own tables. You then get fast dashboards, chart history beyond GA4's retention window, and no per-request API dependency.
// app/Console/Commands/SnapshotAnalytics.php
$row = Analytics::get(Period::create($date, $date), ['activeUsers'], ['date'])->first();
AnalyticsSnapshot::updateOrCreate(['date' => $date], $row);
Fake in tests. Analytics::fake() short-circuits every method to return an empty Collection; pass an array or Collection to control the payload. This makes dashboard feature tests fast and network-free:
Analytics::fake([['fullPageUrl' => '/pricing', 'pageTitle' => 'Pricing', 'screenPageViews' => 42]]);
$this->get('/dashboard')->assertOk()->assertSee('Pricing');
Environment separation. Give staging its own ANALYTICS_PROPERTY_ID (or a dedicated GA4 property) so QA doesn't pollute production reporting, and prefer passing credentials as an array via env (base64-decoded JSON) on ephemeral/serverless hosts where storage_path() isn't persistent.
fetchVisitorsAndPageViews as returning date/visitors/pageViews in one place and activeUsers/screenPageViews/pageTitle in another (v4 vs v5 leftovers). Always dd() a result once before writing Blade templates against it — the GA4 camelCase names are what you'll actually get.ga:* metrics are gone. pageviews → screenPageViews, users → activeUsers, sessions survives. Budget migration time if you're porting an old dashboard.property_id mistakes are the most common setup failure: measurement ID (G-…) instead of numeric property ID, or service account never added to the property. Both surface as PERMISSION_DENIED.Period::days(7) builds Carbon dates in your app timezone; GA4 reports in the property timezone. Plus GA4 has processing latency (hours, longer for some dimensions), so today's numbers always look low. For daily reports, always query yesterday-and-before using explicit Period::create() dates.Period::days(7) computed at 09:00 and 17:00 produce different keys — good for correctness, but it means "cache for a day" rarely behaves as a full day of hits. If you need deterministic caching, build the period from date strings (now()->startOfDay()).ANALYTICS_CACHE_LIFETIME=0 locally or point the package cache.store at array — otherwise you'll chase a bug that's actually a cached response.cache_lifetime_in_minutes and the cache.store block configure different layers. Touching one won't clear the other; to hard-refresh, flush the store.maxResults defaults are low — get() defaults to 10 while the named methods default to 20. Silent truncation is a classic "why is this chart wrong" bug; pass an explicit limit.INVALID_ARGUMENT from Google almost always means an incompatible metric/dimension combination or a typo in a field name. Check the GA4 schema; there's no client-side validation.config(['analytics.property_id' => ...]) immediately before each call inside a dedicated resolver, or bypass the facade and construct the underlying Analytics Data client yourself.RESOURCE_EXHAUSTED) happen; a dashboard should degrade to the last snapshot, not a 500.OrderBy, FilterExpression, NumericFilter, and keepEmptyRows directly, because that's where your real reporting work lives.How can I help you explore Laravel packages today?