jeffersongoncalves/laravel-ga4
Laravel GA4 offers a simple integration for Google Analytics 4, letting you send events and page views to GA4 directly from your Laravel application. Configure your measurement ID and start tracking server-side events quickly.
This is the first release (v1.0.0), so there is no upgrade path to worry about — but there is also no battle-testing yet. Pin the exact version in composer.json ("vendor/package": "1.0.0") until the API surface settles across a couple of point releases, and skim the CHANGELOG before every composer update.
Minimal setup:
composer require vendor/packagephp artisan vendor:publish --tag=ga4-config (check the README for the exact tag — packages vary).storage/app/ga4/service-account.json).GA4_PROPERTY_ID and the credentials path (or inline JSON) in .env.Where to look first: the README's quick-start, then the config file (it tells you which env vars actually matter and whether credentials can be passed inline), then the client/service classes in src/ — that is where you will find whether the package wraps the raw Google SDK or hand-rolls HTTP calls, which determines how you test it.
First use case: a realtime sanity check. Before building anything, confirm auth works:
$active = GA4::runRealtimeReport([
'metrics' => [['name' => 'activeUsers']],
]);
If that returns rows, your credentials and property ID are correct. If it returns an empty set, you are probably authenticated but pointing at the wrong property — check the numeric property ID (not the G-XXXXXXX measurement ID, which is a different thing entirely).
Reporting — cache aggressively. runReport() is the workhorse and the fastest way to burn quota. Wrap it in a cached service rather than calling it from controllers:
$report = Cache::remember("ga4:pageviews:{$days}", now()->addMinutes(15), fn () => GA4::runReport([
'dateRanges' => [['startDate' => "{$days}daysAgo", 'endDate' => 'today']],
'dimensions' => [['name' => 'pagePath']],
'metrics' => [['name' => 'screenPageViews'], ['name' => 'averageSessionDuration']],
'orderBys' => [['desc' => true, 'metric' => ['metricName' => 'screenPageViews']]],
'limit' => 25,
]));
Realtime is for dashboards only. runRealtimeReport() covers roughly the last 30 minutes and supports a much narrower set of dimensions/metrics than standard reports. Use it for "users online now" widgets and nothing else — don't build reporting features on it.
Admin API is provisioning, not runtime. listConversionEvents() / createConversionEvent() belong in an artisan command or a seeder, not in a request cycle:
// app/Console/Commands/SyncGa4Conversions.php
foreach (config('analytics.conversion_events') as $eventName) {
if (! in_array($eventName, $existing, true)) {
GA4::createConversionEvent(['eventName' => $eventName]);
}
}
Keeping the desired list in config makes the command idempotent and lets you run it per environment (staging property vs production property).
Measurement Protocol — send from the server, always queued. This is where most day-to-day value lives: purchases, signups, server-side conversions that the browser tag misses. Never call it inline in a checkout request.
GA4::sendEvent([
'client_id' => $this->clientIdFromGaCookie($request->cookie('_ga')),
'user_id' => (string) $order->user_id,
'events' => [[
'name' => 'purchase',
'params' => [
'transaction_id' => $order->reference,
'value' => (float) $order->total,
'currency' => $order->currency,
'items' => $order->items->map->toGa4Item()->all(),
],
]],
]);
Wrap it in a queued job or a queued event listener with $tries = 3 and a backoff. The Measurement Protocol accepts batches, so if you emit several events for one order, collect them into a single sendEvent() call rather than three HTTP round-trips.
Extract the GA client ID properly. The _ga cookie looks like GA1.1.123456789.1699999999; the client ID is the last two segments (123456789.1699999999). If the cookie is absent (consent-blocked, server-to-server traffic, mobile app), generate a UUID and persist it against the user so future events stitch together. Pass user_id whenever you have it — it makes cross-device reporting actually work.
Testing. Bind a fake or use Http::fake() in your test suite; assert on the outgoing payload rather than on the package's return object, since the response shape for Measurement Protocol is famously uninformative (204 No Content on success).
403 PERMISSION_DENIED. The API call is fine; the account simply isn't a user on that property.G-XXXXXXX yields confusing 404s.batchRunReports if the package exposes it.limit/offset pagination. Silent truncation is easy to miss because the response still looks successful.createConversionEvent() is not idempotent. Calling it twice for the same event name errors out. Always check listConversionEvents() first — hence the guard in the command above.validationBehavior / debug mode) rather than waiting for the UI. Events timestamped more than 72 hours in the past are rejected.php artisan config:cache will bake the credentials path in; make sure it's an absolute path or resolved via storage_path() in the config file, otherwise deploys break in ways that only show up in production..env. Inline JSON in an env var is fragile (escaping, newlines). A file path is easier to rotate and won't blow up your .env parser.How can I help you explore Laravel packages today?