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.
Architecture fit — still a mismatch, and 1.2.1 makes the reason more concrete. freema/ga4-measurement-protocol-bundle remains a Symfony bundle, not a Laravel package: a Bundle class, a DI extension, a config tree (measurement_id, api_secret), and a service definition wrapping br33f/php-ga4-mp. Laravel ships Symfony components but not symfony/framework-bundle or a container wired the way the bundle expects. Installing it yields a config file you cannot consume and a service you cannot resolve.
What 1.2.1 actually tells us. The headline fix (#12) is that DefaultCustomUserIdHandler called Request::getSession(), which throws on stateless routes and API requests — so send() returned an error and the event was silently never sent. That is precisely the shape of a Laravel API/queue-worker request: no session. The patch is a good fix, but it confirms the bundle is architected around a sessionful Symfony HTTP request lifecycle, which is the opposite of where server-side GA4 events originate in a Laravel app. It also confirms the class of failure to expect from this stack: exceptions swallowed into a returned error, invisible in production, with no event delivered.
What is actually valuable here. Unchanged. The real capability lives in br33f/php-ga4-mp: a PSR-18-based client for POST /mp/collect with event/params builders. That library is the integration target; the bundle is a reference implementation of how to configure it.
Integration feasibility. Unchanged and straightforward: bypass the bundle, bind the library in a Laravel service provider — roughly 100–200 LOC for config, a singleton, a facade, and a queueable job.
Technical risk — movement on maturity, none on the fundamentals.
1.2.0...1.2.1, corroborating a sane release history. Still verify the previously future-dated 2026-10-02 tag resolves in your constraint set.user_id. Non-string session user_id values (arrays, objects without __toString()) are now silently discarded rather than sent as "Array". Better than corrupting data, but it is a silent degradation. In your own adapter, validate and log the drop; don't inherit the silence.symfony/framework-bundle, Composer will refuse or pull a parallel framework into Laravel. Confirm before any spike.api_secret is a bearer-equivalent secret in a query string — secret manager only, rotatable without redeploy.204 on accept, not on validation. 1.2.1 is a live demonstration that a client-side error can suppress the send entirely with no upstream signal. Route staging through /debug/mp/collect and handle validationMessages.user_data, user_id, or email/UUID-shaped param needs a documented lawful basis and a hashing story.Key questions for the vendor/stakeholder before committing:
api_secret, and what is the runbook?Recommendation: do not install the bundle. Adopt the pattern. 1.2.1 does not change this — it reinforces it. The bug that was fixed could only bite you if you were running the bundle inside a sessionless request path, which is exactly the Laravel case.
Stack fit. Target Laravel 10/11, PHP 8.1+, consume br33f/php-ga4-mp directly — or hand-roll the ~80-line client against POST https://www.google-analytics.com/mp/collect if the dependency's constraints go stale. The endpoint is stable enough that a thin hand-rolled client is legitimate and removes the transitive-dependency question entirely.
Laravel-shaped wrapper:
config/analytics.php — measurement_id, api_secret, enabled, debug, queue, timeout. Keep the bundle's key names for forward compatibility.Ga4Client interface + MeasurementProtocolClient bound as a singleton in Ga4ServiceProvider.SendGa4Event queued job with $tries = 3, exponential backoff, and a failed() dead-letter sink.Ga4 facade for ergonomics.client_id on every call. Never derive identity from request/session state — that is the coupling that broke upstream, and it is the same coupling that will break you in queue workers and webhook handlers. When identity is absent or unusable, log it; don't silently drop.Compatibility and migration path. Nothing in application code references third-party classes; everything goes through Ga4Client. That is a one-file swap if upstream is abandoned, if you move to server-side GTM, or if the endpoint changes. Upstream's own "Upgrading: no changes needed" confirms low churn — pinning is cheap either way.
Sequencing (each step is a gate):
/debug/mp/collect in staging only, asserting validationMessages is empty for every planned event.What not to do: don't boot the Symfony bundle inside Laravel via a shim, and don't vendor the bundle just to keep its config file.
Maintenance. Better than the prior read: there is an active — if minimal — maintainer shipping fixes and widening the CI matrix (PHP 8.5, Symfony 8.x, PHPStan 2). There is still no community to inherit fixes from and no dependents, so budget for permanent internal ownership of a ~200-LOC adapter. Quarterly review: Google's MP changelog for breaking changes, confirm the pinned version still installs, confirm secret rotation actually happened. Note that upstream dist no longer carries tests — pin your own regression suite over the adapter.
Support. MIT, no vendor, no SLA, 0 dependents. Escalation is your team reading Google's MP docs. Acceptable for a small surface area; not a substitute for a maintained vendor integration. The 1.2.1 turnaround is a mild positive signal on responsiveness to issues.
Scaling. MP processes one event per HTTP request — no documented batch endpoint. Volume translates linearly into outbound requests:
transaction_id dedupes purchases, but custom events sent from both gtag.js and MP double-count. One source of truth per event name, documented in the plan.Failure modes to instrument explicitly:
| Failure | Symptom | Mitigation |
|---|---|---|
Invalid measurement_id / api_secret |
Silent 204, zero data |
Synthetic canary event + Realtime alert |
| Malformed params | Silent drop in prod, visible only in /debug |
Debug endpoint in staging CI |
| Stateless route / no session (the 1.2.1 bug class) | Exception swallowed, send() returns error, event never sent |
Don't use the bundle; pass client_id explicitly; alert on adapter error rate |
| Unusable identity silently discarded | Data gaps attributed to "unknown" | Validate and log drops rather than inheriting silent handling |
| Property throttling | Data gaps under load | Rate limiter + queue concurrency cap |
| Secret leaked in logs | Query string in HTTP client debug output | Redact query params in HTTP middleware |
| Consent violation | Regulatory, not technical | Parameter allowlist enforced in code |
| Upstream abandoned / tests removed from dist | PHP upgrade block, no local audit | Internal interface boundary + hand-rolled fallback + own test suite |
| Clock skew / bad timestamps | Events outside 72h window discarded | Server-side timestamp_micros from NTP-synced host |
Ramp-up. Low. A mid-level Laravel engineer builds and validates the adapter in 2–4 days, plus 1–2 days of analytics/legal review for the measurement plan. The bundle saves perhaps half a day of scaffolding.
Overall verdict. Revised from 19/100 to 22/100: +for a genuine, community-reported fix and a modern CI matrix; −for 1 star, 0 dependents, single maintainer, and tests no longer shipped. 1.2.1 is a good patch to a package you should still not install. The protocol is worth adopting for a narrow, well-justified set of server-side events. The bundle is not. Take the config shape and the library choice as guidance, build a 200-line Laravel-native adapter behind an internal interface, pass client_id explicitly, and accept that ownership of the integration is yours from day one.
How can I help you explore Laravel packages today?