Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Laravel Metrics Ga4 Laravel Package

jeffersongoncalves/laravel-metrics-ga4

Laravel package for pulling Google Analytics 4 metrics into your app, making it easy to query GA4 data and surface reports or dashboard stats without writing boilerplate API code.

View on GitHub
Deep Wiki
Context7

Getting Started

1. Install and publish

composer require <vendor>/laravel-google-analytics-4
php artisan vendor:publish --tag="ga4-settings"   # settings class + migration
php artisan migrate

The package stores credentials through spatie/laravel-settings, so the migration is mandatory — skipping it produces a "settings class not found / table missing" error on the first facade call, not at install time.

2. Do the Google side before touching code

  1. Google Cloud project → enable Google Analytics Data API (not the older Reporting API).
  2. Create a service account, generate a JSON key, download it once.
  3. In GA4 → Admin → Property access management, add the service account's client_email as Viewer.
  4. Copy the numeric Property ID (Admin → Property Settings), not the G-XXXXXXX measurement ID.

Step 3 is the one people forget. Without it you get a perfectly valid JWT and a 403 PERMISSION_DENIED, which looks like an auth bug but isn't.

3. Configure

GA4_PROPERTY_ID=123456789
GA4_SERVICE_ACCOUNT_JSON='{"type":"service_account","project_id":...}'

Then php artisan config:clear. The seeder reads these on first boot; changing them later requires re-seeding or updating the settings record, because the values are persisted (encrypted) rather than read live.

4. First call

use GA4;

$visitors = GA4::visitors(now()->subDays(7), now());

Start with visitors() for a fixed date range and confirm a non-zero number. If that works, auth, property ID, and permissions are all correct — every other method is the same plumbing.

Where to look first: the settings class (to see how the key is decrypted and which cache store holds tokens), then the facade class (to learn the real argument order per method — the release notes list names, not signatures). Read runReport early; it's the escape hatch for anything the convenience methods don't cover.

Implementation Patterns

Wrap the facade in one app-level service. Don't call GA4:: from ten Blade templates. Put a thin AnalyticsService (or repository) in front of it — it gives you one place to cache, one place to catch exceptions, and one place to swap in fixtures during tests.

Cache aggressively, because GA4 data is not real-time anyway. Standard reports lag 24–48h, so a 15–60 minute cache costs nothing in accuracy:

public function weeklyVisitors(): int
{
    return Cache::remember('ga4.visitors.7d', now()->addMinutes(30), fn () =>
        GA4::visitors(now()->subDays(7), now())
    );
}

Key the cache on property ID as well as the period if you ever serve more than one property.

Dashboard widget. timeseries() for the sparkline, visitors() for the headline number with a period-over-period delta (two calls: current range, immediately preceding range). Keep both inside the same cache entry so the delta can never be computed against a stale baseline.

Tables and leaderboards. pages(), sources(), countries(), browsers(), devices() are the same shape — a breakdown with a metric. Normalise them into a single array<label, value> DTO in your service layer so your Blade/Inertia components are generic and you're not writing five near-identical views.

Realtime widget. realtimeVisitors() hits a different endpoint with its own quota. Cache it for 10–30 seconds rather than per-request, and never put it on a page that polls faster than that.

Scheduled warm-up. A small command on everyFifteenMinutes() that refreshes your most-used cache keys keeps user-facing requests off the API entirely:

Schedule::command('analytics:warm')->everyFifteenMinutes()->withoutOverlapping();

Error handling — branch on the exception type, not the message:

try {
    $data = GA4::pages($from, $to);
} catch (RateLimitException $e) {
    $data = Cache::get($key, []);          // serve stale rather than fail
} catch (AuthenticationException $e) {
    report($e);                            // page someone: credentials or property access broke
    $data = [];
} catch (GA4Exception $e) {
    report($e);
    $data = [];
}

AuthenticationException is an ops alarm (key revoked, service account removed from the property, clock drift on the signing host). RateLimitException is expected traffic and deserves a cache fallback, not an alert.

Custom metrics via runReport. Anything outside the five aliases goes through the raw report call. Keep those calls in your service layer alongside the typed ones so the metric-name mappings the package provides and the raw GA4 names you use aren't mixed in a controller.

Multi-property / multi-tenant. The package is built around a single property from env. If you need per-tenant properties, resolve the settings record per tenant (or bind a configured client instance in the container) rather than trying to mutate env at runtime — settings are cached, and re-seeding mid-request is a good way to poison that cache.

Gotchas and Tips

Property ID vs measurement ID. G-XXXXXXX will fail. You want the 9-ish digit numeric property ID. This is the single most common misconfiguration.

The service account needs access inside GA4. Cloud IAM roles are irrelevant here. Admin → Property access management → add client_email as Viewer. If auth succeeds but every report 403s, this is why.

The JSON key in .env is fragile. Multi-line values and embedded quotes break dotenv parsing, and the newlines inside private_key must survive intact. If you get OpenSSL / openssl_sign(): supplied key param cannot be coerced, the key was mangled by env handling. Safer options: store the JSON file and reference its path, or base64-encode the whole blob and decode in your own bootstrap. Also make sure config:cache was re-run.

APP_KEY rotation breaks decryption. The service account JSON is stored encrypted via spatie/laravel-settings. Rotating APP_KEY without re-seeding produces decrypt failures on every call. Document this in your runbook.

Metric semantics won't match Universal Analytics. visitors and visits map to GA4 users and sessions, and those are not 1:1 with the old UA concepts. bounce_rate in GA4 is derived from engagement (roughly 1 - engagementRate), so it behaves differently from what a UA-trained stakeholder expects. visit_duration is average session duration and is heavily skewed by single-page sessions. Sanity-check the mappings against a report you can see in the GA4 UI before anyone quotes a number in a meeting.

"Today" is never complete. The current day's data is partial and revisions arrive for 24–48h. For daily summaries, report on yesterday and earlier; showing today's number next to last week's full day is misleading.

Timezone. GA4 reports in the property's timezone, not config('app.timezone'). If they differ, your "last 7 days" boundary won't match the client's mental model. Either align them or label charts explicitly.

Data thresholding and cardinality. With certain signals enabled, GA4 withholds rows from reports containing demographics — you'll get totals that look plausible with silent gaps. High-cardinality breakdowns (e.g. full page URLs with query strings) also get bucketed into (other). Both are GA4 behaviour, not package bugs; don't burn a day debugging the client.

Quotas are per-property. The Data API has token quotas and concurrent-request limits per property per day. A per-user dashboard hitting uncached endpoints will exhaust them by mid-morning on a busy day. Cache, batch your widgets into fewer requests, and prefer one timeseries/breakdown call over a loop.

Token caching vs. settings cache are two separate caches. Access tokens are cached by the package; the settings record is cached by spatie/laravel-settings. In tests or queue workers with the array cache driver, both behave differently than in production — the token is re-minted every time, which is slow but fine, whereas a stale settings cache is a real bug. If a rotated key "isn't taking effect", clear both.

Testing. No facade fake is advertised, so plan on Http::fake() for the API boundary or binding a stub client into the container behind your own service interface. That's another argument for the service-layer wrapper — it's the seam you actually mock.

Extension points. The settings class is the natural place to override how credentials are resolved (e.g. Secrets Manager instead of env). The typed exception hierarchy is worth catching at a base level and re-throwing as your own domain exception so the rest of the app doesn't import package classes. runReport is the supported escape hatch for anything the curated methods miss — reach for it rather than forking.

Version support. PHP 8.2+ and Laravel 11+ only. On a Laravel 10 or PHP 8.1 app this simply won't install; check before promising a dashboard in a legacy codebase.

Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
elsayed85/laravel-api-exceptions
ouhssini/laravel-demo-mode
daworks/ncloud-outbound-mailer-driver
gp247/cms
ignibyte/aic-gates
nexa-merchant/blog
novasa/laravel-language-center
opencage/geocode
adolfoholzer/parameters
livenetworks/ln-starter
shitoudev/permissionmanager
johannesclimacus/laravel-tui
hocvt/seotools
vitalybaev/sypexgeo
amphibee/themosis-ignition
gito-me/fast-excel
frankyso/laravel-ipaymu
geolize-studio/laravel-chile
boomerangui/chat
cgv/localized-resource