fluffydiscord / google-analytics-bundle
GA4 measurement for Symfony: typed event DTOs, a dataLayer collector, Google Consent Mode and a Measurement Protocol client.
Package info
github.com/FluffyDiscord/google-analytics-bundle
Type:symfony-bundle
pkg:composer/fluffydiscord/google-analytics-bundle
Requires
- php: >=8.3
- ext-json: *
- psr/log: ^2.0 || ^3.0
- symfony/config: ^7.4
- symfony/dependency-injection: ^7.4
- symfony/http-foundation: ^7.4
- symfony/http-kernel: ^7.4
- symfony/messenger: ^7.4
- symfony/service-contracts: ^3.0
- twig/twig: ^3.0
Requires (Dev)
- phpunit/phpunit: ^10.5
- symfony/http-client: ^7.4
README
GA4 measurement for Symfony. Typed event DTOs, a request-scoped dataLayer collector, Google Consent Mode v2, and a Measurement Protocol client — no third-party analytics library.
Requires PHP 8.3+, Symfony 7.4 LTS. Server-side sending needs symfony/http-client.
Install
composer require fluffydiscord/google-analytics-bundle
Configuration
fluffy_discord_google_analytics: container_id: '%env(GTM_CONTAINER_ID)%' # empty renders no tags measurement_id: '%env(GA4_MEASUREMENT_ID)%' # empty sends nothing server-side api_secret: '%env(GA4_API_SECRET)%' # empty sends nothing server-side endpoint: eu # 'eu' | 'global' server_side_events: ['purchase'] http_client_service: http_client # point at a scoped client to control timeout wait_for_update_ms: 500 # Consent Mode wait_for_update header_payload_limit_bytes: 4096 # cap on the X-Analytics-Events response header stimulus_controller_name: ga4-data-layer consent: cookie_name: cc_cookie category_signals: analytics: [analytics_storage] targeting: [ad_storage, ad_user_data, ad_personalization]
All keys are optional. With no container_id the tags render nothing; with no measurement_id/
api_secret nothing is sent server-side — a fresh environment tracks nothing.
Rendering
{{ google_analytics_head() }} {# in <head> #}
{{ google_analytics_noscript() }} {# immediately inside <body> #}
google_analytics_head() emits, in order: the dataLayer/gtag bootstrap, the Consent Mode
defaults, one push per collected client-side event — preceded by {ecommerce: null} only when that
event carries an ecommerce object — then the GTM container snippet. The bootstrap element carries data-controller, which mounts the data layer
bridge described under AJAX and Live Components.
Dispatching events
Inject DataLayerCollector and collect event DTOs during the request:
use FluffyDiscord\GoogleAnalyticsBundle\DataLayer\DataLayerCollector; use FluffyDiscord\GoogleAnalyticsBundle\Event\Purchase; use FluffyDiscord\GoogleAnalyticsBundle\Event\Item; $collector->collect(new Purchase( transactionId: 'T-1000', currency: 'CZK', value: 129900, // minor units (integer cents) items: [new Item(itemId: 'SKU-1', itemName: 'Widget', price: 129900, quantity: 1)], ));
Monetary amounts are integers in minor units. Events collected on an HTML response render inline via
google_analytics_head(). Every other response — JSON, a Live Component fragment, any non-HTML body
— carries the leftover events base64-encoded on the X-Analytics-Events response header, and a
redirect defers them to the next page load. A payload larger than header_payload_limit_bytes
defers instead of being truncated.
AJAX and Live Components
The bundle ships a Stimulus controller that pushes those headers into the dataLayer. Register the
package the way your build registers Symfony UX packages:
{
"devDependencies": {
"@fluffydiscord/google-analytics-bundle": "file:vendor/fluffydiscord/google-analytics-bundle/assets"
}
}
Auto-registration names the controller ga4-data-layer, which is what google_analytics_head()
renders. A build that registers controllers by hand can keep that identifier:
import DataLayerController from '@fluffydiscord/google-analytics-bundle/src/data_layer_controller.js'; application.register('ga4-data-layer', DataLayerController);
or pick another one and tell the bundle about it through stimulus_controller_name.
The controller subscribes to live:connect and hooks each Live Component's render:started, so
every component re-render pushes the events its response collected. Nothing is monkey-patched and
@symfony/ux-live-component is not a dependency — without Live Components the listener simply never
fires.
Your own fetch calls hand their response to the same helper:
import { pushAnalyticsEvents } from '@fluffydiscord/google-analytics-bundle/src/analytics_events.js'; const response = await fetch(url, { method: 'POST' }); pushAnalyticsEvents(response);
Each pushed payload is also dispatched on document as an analytics:event CustomEvent whose
detail is the payload, for code that needs to react to a server-collected event without reading
the dataLayer.
User data on a purchase or a lead
Purchase and GenerateLead take an optional UserData (e-mail, phone). It is emitted client-side
only — under ecommerce.userData for the purchase, at the top level as userData for the flat lead
— for Tag Manager to hash into an enhanced conversion. It never reaches the Measurement Protocol
params, which must stay free of personal data.
new Purchase( transactionId: 'T-1000', currency: 'CZK', userData: new UserData(email: 'jana@example.com', phone: '+420777123456'), );
Ecommerce and plain events
GA4 nests ecommerce parameters under an ecommerce object; everything else is a flat push. Each
event decides for itself through getDataLayerObjectKey(), and the base Ga4Event returns null,
so a custom event is flat unless it opts in:
// ecommerce — AddToCart, ViewItem, Purchase, ViewItemList, the promotion events… { event: 'add_to_cart', ecommerce: { currency: 'CZK', value: 12.99, items: [ … ] } } // plain — Login, SignUp, Search, GenerateLead, anything custom { event: 'generate_lead', event_category: 'contact form', userData: { email: 'jana@example.com' } }
{ecommerce: null} is pushed ahead of ecommerce payloads only; a flat payload never clears an
object it does not write.
Events without a DTO of their own go through CustomEvent, which takes the name, the parameters,
and optionally an object key to nest them under and a de-duplication key:
$collector->collect(new CustomEvent('newsletter_signup', ['method' => 'footer']));
Available events
AddPaymentInfo, AddShippingInfo, AddToCart, AddToWishlist, BeginCheckout, CustomEvent,
GenerateLead, Login, Purchase, RemoveFromCart, Search, SelectItem, SelectPromotion,
SignUp, ViewCart, ViewItem, ViewItemList, ViewPromotion.
Transport
Each event declares supportsClientSide() and supportsServerSide() in code, not config.
Purchase is emitted both ways: client-side for Tag Manager, and server-side through the
Measurement Protocol when listed in server_side_events. GA4 de-duplicates a transaction id in the
ecommerce reports, but not the event count and not a Tag Manager conversion — configure the
conversion on one of the two, or revenue is counted twice.
server_side_events names which events are also sent through the Measurement Protocol. Sends run
through Messenger (SendMeasurementProtocolEvent), so a messenger:consume worker is required for
async delivery.
Client id, session id, consent signals and hashed user data are snapshotted at dispatch inside the HTTP request — a Messenger worker has no request and cannot read cookies.
Consent
ConsentStateReader maps a first-party consent cookie (cc_cookie by default) onto the four Consent
Mode signals via consent.category_signals. A cookie without a categories array reads as all
denied. With analytics consent denied there is no _ga cookie, so nothing is sent server-side —
consent is a precondition of a send, not a payload field.
Idempotency
Provide a SentEventLogInterface implementation to stop an event repeating. The bundle ships
NullSentEventLog, which claims every event as unsent — correct only for events that are harmless to
repeat.
Upgrading from 2.x
3.0 stops wrapping every payload in ecommerce. Login, SignUp and Search now push their
parameters at the top level, so a Tag Manager variable reading ecommerce.search_term or
ecommerce.method has to be repointed at search_term / method. Ecommerce events are unchanged.
Custom Ga4Event subclasses that relied on the old wrapping must override
getDataLayerObjectKey() and return 'ecommerce'.
Licence
MIT.