luketowers/laravel-ga4-event-tracking
Server-side Google Analytics 4 event tracking for Laravel. Send events to GA4's Measurement Protocol from your backend using a simple, fluent API, keeping credentials out of the browser and giving you full control over the payloads you send.
The package wraps the GA4 Measurement Protocol — it sends events to Google Analytics from your server. It is not a reporting/read package (that's spatie/laravel-analytics territory), and it is not a replacement for gtag.js; it's the thing you reach for when the browser can't or won't report (ad blockers, iOS ITP, post-purchase webhooks, refunds, backend-only flows, offline conversions).
composer require luketowers/laravel-ga4-event-tracking. On Laravel 13 you need v2.2.1 or later (that's the entire content of this release — the illuminate/* constraint was widened to ^13.0). On Laravel 11/12, ^2.2 resolves fine.php artisan vendor:publish and select the package's config tag, which lands config/ga4-event-tracking.php.G-XXXXXXX) and that API secret. Put them in .env as GA4_MEASUREMENT_ID and GA4_API_SECRET. Never commit the secret; it grants write access to your property.purchase from an order-paid listener:use LukeTowers\GA4EventTracking\Facades\GA4;
GA4::event('purchase', [
'transaction_id' => $order->number,
'value' => $order->total,
'currency' => 'USD',
'items' => $order->lines->map(fn ($l) => [
'item_id' => $l->sku,
'item_name' => $l->name,
'price' => $l->unit_price,
'quantity' => $l->qty,
])->all(),
]);
The first use case worth building is almost always purchase reconciliation: your browser-side purchase is lost for a meaningful slice of users, and a server-side duplicate is deduplicated by GA4 on transaction_id, so sending it is safe.
Look first at: the published config file (it exposes the client ID, session ID, user ID, debug_mode, and engagement-time knobs you will need to set per-event), then the facade's method list in the README — the surface is small, roughly event(), setUserId(), setClientId(), setSessionId(), plus config-driven defaults.
Don't call it inline in a controller. The package issues a synchronous HTTP request inside the request lifecycle. Put it behind a queued job, or dispatch your own event and let a queued listener do the work. Wrap in DB::afterCommit() if the event is tied to a write, so you never report a purchase that gets rolled back.
Stitch the server event to the browser session. This is the pattern that separates a working integration from a pile of "direct / (none)" traffic. On the front end, read the _ga cookie (GA1.1.<client_id>.<timestamp> — the client ID is the last two dot-separated segments) and the _ga_<STREAM_ID> cookie, POST them to your backend at checkout, and persist them on the order. Then at event time:
GA4::setClientId($order->ga_client_id);
GA4::setSessionId($order->ga_session_id);
GA4::event('purchase', [...]);
Without a client_id, Measurement Protocol still accepts the hit but attributes it to a synthetic user — no source, no session, no attribution. Setting user_id as well as client_id is what gets you cross-device stitching; setting only user_id is not a substitute for client_id on web streams.
Model it as a domain event, not a call site. Define App\Events\OrderPaid, and one queued listener that owns all GA4 mapping. When marketing asks for a new parameter six months from now you change one file, and you can replay historical events from the queue.
Centralise the payload shape. GA4 is strict about items arrays and silently drops malformed params. Build a small Ga4Payload/mapper class (or per-event data objects) that converts your models into GA4's expected keys — item_id, item_name, price, quantity, item_category. Treat the GA4 parameter reference as your schema; you'll be reading it often.
Use it for the things the browser can't do: refunds (refund with the original transaction_id), subscription renewals, sign_up from an API, lead creation from a webhook, and backfilling a historical import. These are the highest-value uses and the ones no client-side tag can cover.
Testing: Http::fake() on *.google-analytics.com/** (and the debug/mp/collect endpoint) and assert the JSON body. Assert both URL query params (measurement_id, api_secret) and the client_id, events[0].name, and events[0].params shape. Without fakes your test suite fires real hits into your production property.
https://www.google-analytics.com/debug/mp/collect (the package's debug_mode flag helps too); the response there contains a validationMessages array telling you exactly which parameter is wrong. Do this before you trust a single event.client_id with no engagement_time_msec shows up in Realtime but vanishes from standard reports. If events "disappear" overnight, this is almost always why. Set a non-zero engagement time in config or per event.params key that isn't a GA4 default won't appear in any report until you register it as a custom dimension in the GA4 admin, and it won't be usable in audiences. Decide your custom parameter names up front, in snake_case, and register them the day you ship.config:cache will bite you. All credentials live in config/ga4-event-tracking.php reading from env(). If you change GA4_API_SECRET and don't php artisan config:cache, you'll spend an hour debugging hits that are silently dropped for an invalid secret.transaction_id for purchase. Retried jobs will double-count sign_up and refund. Guard with a ga4_reported_at column or a unique queued job, and make your listener idempotent — queued listeners will retry.GA4::event() calls through the app — the package's client is a thin wrapper over Laravel's HTTP client, so binding your own implementation is the clean seam.illuminate/support constraint for Laravel 13. If you're upgrading the app to Laravel 13, this is your floor — and it's a good moment to check whether the package's HTTP call is still blocking your checkout path and to confirm your client_id stitching survived the framework upgrade's cookie handling changes.How can I help you explore Laravel packages today?