fluffydiscord/google-analytics-bundle

GA4 measurement for Symfony: typed event DTOs, a dataLayer collector, Google Consent Mode and a Measurement Protocol client.

Maintainers

Package info

github.com/FluffyDiscord/google-analytics-bundle

Type:symfony-bundle

pkg:composer/fluffydiscord/google-analytics-bundle

Transparency log

Statistics

Installs: 35

Dependents: 1

Suggesters: 0

Stars: 0

Open Issues: 0

v3.0.0 2026-08-24 09:36 UTC

This package is auto-updated.

Last update: 2026-08-24 09:37:35 UTC


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.