freema/ga4-measurement-protocol-bundle
Symfony bundle for sending server-side events to Google Analytics 4 via the Measurement Protocol, with straightforward configuration for tracking conversions and user activity from your PHP application.
This is a Symfony bundle wrapping a GA4 Measurement Protocol client. As a Laravel developer, the first decision is whether you actually want the bundle or just the underlying PHP client. In Laravel you will not get Symfony bundle auto-discovery, so the practical path is usually:
AppServiceProvider.Minimal Laravel wiring:
// app/Providers/AppServiceProvider.php
use Freema\Ga4MeasurementProtocol\Client;
public function register(): void
{
$this->app->singleton(Client::class, fn () => new Client(
config('services.ga4.measurement_id'),
config('services.ga4.api_secret'),
));
}
// config/services.php
'ga4' => [
'measurement_id' => env('GA4_MEASUREMENT_ID'),
'api_secret' => env('GA4_API_SECRET'),
],
For a Symfony application, install the bundle and configure it under freema_ga4_measurement_protocol with measurement_id and api_secret; the client is then autowirable as a service. Pin the bundle to ^1.2.1 if you go this route — 1.2.1 fixes a bug where events were silently dropped on any request without a session, which is exactly the situation you hit when you call the tracker from a queue worker, an artisan command, a webhook, or a stateless API route.
Where to look first:
src/DependencyInjection/Configuration.php for the supported config tree.services.xml / services.yaml) for the client service ID and whether it is autowirable.DefaultCustomUserIdHandler) — see the gotchas, this is where the 1.2.1 fix landed and it changes how user_id is populated.freema/ga4-measurement-protocol-php client for request/event classes. Note that as of 1.2.1 the Composer download no longer ships tests, so read the suite on GitHub rather than in vendor/.First use case: send a server-side purchase event after an order is paid, using the browser's _ga cookie value as client_id. This is the highest-value use of the Measurement Protocol because ad blockers, iOS tracking prevention, and checkout redirects regularly lose client-side purchase events.
Wrap the client in a domain gateway. Do not let GA4 payload details leak into controllers or jobs. Create App\Services\Analytics\Ga4Tracker with methods like purchase(Order $order, string $clientId), signUp(User $user, string $clientId), and lead(Lead $lead, string $clientId). This makes event naming, parameter mapping, consent checks, and testing consistent.
Resolve client_id and session_id deliberately. GA4 requires a client_id; without one, events may be accepted but not attributed. Read it from the _ga cookie (GA1.1.123456789.1234567890 → 123456789.1234567890). Read session_id from the _ga_<CONTAINER> cookie if you need session-level attribution. If the cookie is missing, fall back to a persisted UUID for logged-in users, or pass user_id as well. Do not generate a fresh random ID on every event, or you will fragment the user's history.
Don't rely on the bundle's automatic user_id. DefaultCustomUserIdHandler reads the user ID out of the request session. In Laravel there is no Symfony session on most code paths — queued jobs, console commands, webhooks, stateless API routes — so you will get no user_id at all. Pass user_id explicitly through your gateway. Before 1.2.1 the failure was worse than "no user ID": Request::getSession() threw on sessionless routes, the client swallowed it, send() returned an error, and the event was never sent. If you are on 1.2.0 or earlier and calling the tracker from a job, assume your events may be missing entirely.
Check the return value of send(). This client signals failure by returning an error object, not by throwing. The 1.2.1 session bug is a textbook example: the send returned an error and nobody noticed for two minor versions. Log non-success returns and alert on them.
Fire after the database transaction commits. Use Laravel events/listeners or DB::afterCommit() so you never send a purchase event for an order that later rolls back.
Queue the send. Measurement Protocol calls are external HTTP requests. Dispatch a job (SendGa4Event) with retries and logging. Keep the job payload small: event name, client ID, user_id, and the minimal parameters. Reconstruct or pass the relevant order data explicitly — remember the job has no session, so nothing is inherited from the originating request.
Use the debug endpoint in non-production. The GA4 debug endpoint (/debug/mp/collect) returns validationMessages. Assert that this array is empty in staging before trusting a payload shape. Never enable debug mode in production unless you are actively troubleshooting.
Batch where it makes sense. The protocol accepts multiple events per request. Batch low-value events (page views, minor interactions) rather than making one HTTP call per event. Keep high-value commerce events isolated so a bad payload does not invalidate a batch.
Map ecommerce events to GA4's expected names. Use purchase, begin_checkout, add_to_cart, sign_up, generate_lead, and login. For purchase, include transaction_id, value, currency, and items[] with item_id, item_name, price, and quantity. GA4's built-in reports expect these names and parameters; custom names will only appear in explorations.
Gate on consent. If you run in the EU/UK or have consent-mode requirements, do not send events until analytics consent is recorded. Store the consent decision with the order/user and check it inside the gateway before calling the client.
Keep a local audit trail. Log the event name, client ID, user_id, timestamp, and the client's return status. Because the Measurement Protocol is fire-and-forget and this client reports failure by return value rather than exception, this log is often the only way to answer "did we send the purchase?" — and it is what would have surfaced the 1.2.1 bug on day one.
DefaultCustomUserIdHandler called Request::getSession(), which throws on stateless routes and API requests; send() then returned an error and the event never went out. On 1.2.0 or below, treat any tracker call from a job, command, or webhook as unreliable. Upgrade and re-send anything historical you care about.user_id is best-effort, not a feature you should depend on. Post-1.2.1 the handler simply returns no user ID when there is no session, and it now discards session values that cannot be cast to a string. Before, an array user ID was happily sent as the literal string "Array", polluting your user attribution. Set user_id yourself.client_id is mandatory in practice. user_id alone is not a substitute for attribution. If you cannot obtain a client ID, decide explicitly whether to skip the event rather than sending an unusable one.session_id and engagement_time_msec where appropriate; otherwise events can appear in standard reports but behave oddly in realtime.timestamp_micros for "scheduled" events.user_id with a stable internal identifier.vendor/; read the suite and release notes upstream. Pin the version in composer.json, and inspect the client's HTTP layer for timeout and error-handling behavior before relying on it in a revenue-critical path.user_id injection, and multi-property routing.How can I help you explore Laravel packages today?