13 min readSep 27, 2026by jakub

Changelog

Current version: 1.1.0

All notable changes to qoliber/data-layer are recorded here. The format follows Keep a Changelog, and versioning follows SemVer.

[1.1.0] — 2026-09-26

Added

  • README: storefront theme requirements. The storefront layer targets Luma (RequireJS); Hyvä support ships separately as qoliber/data-layer-hyva. Known limitations now spells out what does and does not work on Hyvä without it, and that Hyvä Checkout emits no add_shipping_info / add_payment_info.
  • Retries for server-side delivery. A transient failure (no response, timeout, HTTP 429 or 5xx) is retried for that destination only, after 5 min, 15 min, 1 h and 4 h (at most 5 attempts), by the new qoliber_datalayer_retry_dispatch cron from the new qoliber_datalayer_dispatch_retry table. The Dispatch Log records the real attempt number.
  • Cancelled orders send a server-side refund for the cancelled part (event id ref-cancel-<increment_id>), so an unpaid redirect-payment order or the cancelled original of an edited admin order no longer keeps its reported revenue.
  • Multishipping purchase client-side: the multishipping success page pushes one purchase per order.
  • view_item_list on search results (list search_results), which also enables select_item there on Luma and Hyvä.
  • Items in view_cart, begin_checkout, add_shipping_info, add_payment_info, purchase and refund carry item_category…item_category5, resolved in batch.
  • GA4 hits carry session_id from the _ga_<measurement id> cookie; Meta events carry event_source_url.
  • TikTok Test Event Code setting; destination:test --live.
  • GA4 Also Send Browser-Tracked Events setting (off by default, see Changed).
  • Extension points: Api\EventSelectiveDestinationInterface, Model\Context\MultiContextProviderInterface, and a context_provider layout argument on Block\PageEvents.
  • refund.adjustment template variable.

Changed

  • The GA4 destination takes only purchase and refund by default. The Measurement Protocol cannot deduplicate, so add_to_cart / remove_from_cart sent server-side were counted twice whenever GTM's GA4 tag also fired. Opt back in with Also Send Browser-Tracked Events.
  • Cart events are built from a before/after diff of the cart: grouped products report every child added, quantity changes on the cart page and minicart report the difference, reconfiguring a line reports the removal and the addition (nothing when unchanged), and wishlist-to-cart, add-all, shared wishlists, related products, "Clear Shopping Cart" and the multishipping address step now emit cart events.
  • One price convention across the funnel: item price is net of tax and after the line's discount, in the event's currency. add_to_cart, remove_from_cart, view_item and view_item_list now match checkout and purchase; purchase/refund shipping is net of tax (tax already includes shipping tax).
  • Debug panel needs an opt-in: enabling it only makes it available; a browser opts in with ?qdl_debug=1, and Developer Client Restrictions IPs are honoured via the uncached qoliber_datalayer/debug/access endpoint.
  • destination:test no longer records a real conversion by default: GA4 goes to its validation endpoint; Meta and TikTok need a Test Event Code, or --live.
  • Template cache tags are per event: saving a template purges only the pages embedding its event, and purges again once the save is committed.
  • REST PUT of a template is a partial update; POST without is_enabled creates an enabled template; getList can filter on store_id / store_ids.
  • The dedup ledger moved from per-tab sessionStorage (qdl-pushed) to a shared localStorage entry (qdl-pushed-ids, 200 ids, 24 h); the old ledger is adopted once.

Fixed

  • Orders created or edited in the admin sent the operator's cookies, IP, User-Agent, consent and attribution as the shopper's, and an edited order counted its revenue twice. Admin, cron, CLI and token-authenticated API requests now dispatch anonymously and never queue browser events.
  • Login as Customer sent a login from the admin's device; registering sent both sign_up and login, and sign_up lacked the customer's email hash.
  • Visitor events were pushed again in every new tab while the customer-data cache held them.
  • {ecommerce: null} is now pushed before every ecommerce event, so GTM no longer merges the previous event's items into the next (e.g. select_item after view_item_list).
  • refund doubled the quantity of configurable products and reported dynamic bundles inconsistently with purchase; its value ignored adjustment refunds and fees.
  • add_to_cart reported the base-currency price labelled with the display currency.
  • Dynamic-price bundles lost their discount, and item prices were wrong when discounts apply to prices including tax.
  • "Static block only" category pages loaded the whole unpaginated category on every cache miss and reported products that were not shown; a disabled page event still built its context.
  • Category paths could come from another store's root category or from disabled categories.
  • view_item_list brand was empty unless the brand attribute was Used in Product Listing.
  • With Consent Mode disabled, server-side events were stripped as if consent were denied.
  • Token-authenticated REST checkout calls started unused PHP sessions.
  • Missing customer-data section invalidations for wishlist-to-cart, addgroup, updateItemOptions and multishipping routes left events waiting for an unrelated action.
  • A partial REST template save skipped the uniqueness check and could reset a template's store views; a template whose store view was deleted showed as "All Store Views" (it is now disabled and shown as "No Store Views"); non-existent store IDs surfaced a raw SQL error.
  • The InstallNewEventTemplates patch could recreate default templates a merchant had deleted.
  • The template form data source had no ACL resource.
  • module:uninstall --remove-data left queued dispatch messages and patch_list rows, so a reinstall came back with no templates.
  • etc/db_schema_whitelist.json was missing qoliber_datalayer_order_attribution.
  • Events a destination cannot map are no longer queued for it or logged as skipped.
  • Phone hashing is normalised per destination (Meta digits only, TikTok E.164).
  • The attribution cookie is capped at 1.5 KB, dropping the least valuable fields first.
  • CSP whitelist: *.googletagmanager.com, *.analytics.google.com, *.google.com.
  • EventDispatchService swallowed \Exception but not \Error.
  • The template engine is built once per request; event data is reduced to arrays and scalars before rendering.
  • Validation messages are translatable, and en_US.csv covers every phrase.

[1.0.0] — 2026-08-11

Initial release.

Added

  • First-touch attribution capture. Ad click IDs (gclid, gbraid, wbraid, fbclid, msclkid, ttclid) and campaign parameters (utm_*), plus the landing page and referrer, are captured in the browser and stored against the order at placement in qoliber_datalayer_order_attribution. First touch wins, so a shopper acquired by an ad who later returns organically stays credited to the ad. Capture is client-side by necessity: landing pages are full-page cached, so PHP never runs on the cache hit that serves a paid click. The purchase payload carries the stored values, which is what makes them available to a queue consumer long after the browser is gone, and the shipped purchase template emits them.
  • Reliable purchase, independent of the success page. The server-side event fires at order placement (checkout_submit_all_after), so a shopper who pays and closes the tab — or whose payment provider fails to redirect back — is still counted. The deterministic ord-<increment_id> id means the success page's client-side pixel deduplicates against it rather than double-counting.
  • Item collections capped to GA4's 200-item maximum (Model\Item\ItemLimiter). The Measurement Protocol rejects an oversized request body outright, so an uncapped 300-line B2B order would lose its purchase event entirely rather than merely being truncated. Truncation is logged with the event name and both counts.
  • DJson-templated data layer: every emitted event is defined by an admin-editable JSON template (qoliber/djson engine), with store-view scoping and one-enabled-template-per-scope resolution. Fourteen GA4 ecommerce defaults are installed on setup: view_item, view_item_list, add_to_cart, remove_from_cart, view_cart, begin_checkout, add_shipping_info, add_payment_info, purchase, refund, add_to_wishlist, login, sign_up, search.
  • GA4 checkout funnel: add_shipping_info and add_payment_info, collected by plugins on the checkout shipping-information and set-payment-information REST calls — customer and guest checkout — and delivered on the checkout page in GA4 order, before purchase. Funnel events carry a deterministic per-quote event ID, so the repeated collection Magento performs during place-order collapses to a single push while a genuine payment-method or shipping-tier change still re-fires.
  • select_item, emitted client-side when a shopper clicks a product in a listing: it reuses the embedded view_item_list payload and needs no template or extra markup.
  • GA4 item parameters: item_brand (from a configurable brand attribute), the category hierarchy (item_category/item_category2…5), item_variant (configurable option selection), and per-item discount and index. Optional fields are wrapped in @djson if so nulls are omitted rather than emitted.
  • Custom/dynamic variables: a config namespace exposing admin-mapped store-config values to every template ({{config.name}}), and per-item product attributes under item.custom ({{item.custom.color}}).
  • Admin UI: template grid + form with a CodeMirror JSON editor, DJson Validate and sample-data Preview, mass enable/disable/delete; read-only server-side Dispatch Log grid; system configuration under Qoliber → Data Layer; full ACL tree.
  • Storefront delivery: CSP-safe GTM container + Consent Mode v2 default snippets; FPC-safe split between server-rendered page events (inert JSON script blocks) and session-queued visitor events delivered via the qoliber-datalayer customer-data section; client-side event-id deduplication.
  • Server-side transmission (queue-based, consent-gated): GA4 Measurement Protocol (incl. GTM Server-Side endpoint override), Meta Conversions API (shared event_id pixel dedup, SHA-256-hashed identifiers), TikTok Events API; per-attempt dispatch log with configurable retention + nightly purge cron. The refund event is server-side only, fired from credit-memo creation with a deterministic ref-<increment_id> ID.
  • Consent layer: Google Consent Mode v2 defaults per store view, qdl_consent cookie contract, replaceable ConsentStateProviderInterface, consent snapshots captured at event time and enforced at dispatch time.
  • Service contracts: TemplateRepositoryInterface (+ REST CRUD under /V1/qoliber-datalayer/templates), TemplateRendererInterface, EventDataProviderInterface, DestinationInterface, EventDispatchServiceInterface, EventProcessorInterface.
  • CLI: qoliber:datalayer:template:reset, qoliber:datalayer:render, qoliber:datalayer:destination:test (sends a synthetic event to a configured destination and reports the HTTP result) and qoliber:datalayer:ga4:validate, which renders each template as the dispatcher would and asks GA4's debug endpoint whether it understands the result — the live endpoint answers 204 to anything, so this is the only way to catch a template that is valid DJson but describes an event GA4 discards (see docs/VALIDATION.md).
  • Storefront debug panel: an opt-in floating overlay listing every window.dataLayer push, for development and QA. Off by default.
  • Extension points: provider/destination/function DI pools, the qoliber_datalayer_event_collected payload-mutation event, qoliber_datalayer_dispatch_before / qoliber_datalayer_dispatch_after around every server-side delivery attempt, and a replaceable consent provider preference.
  • Dedicated qoliber_datalayer cache type with automatic FPC invalidation on template save.
  • Test suites: PHPUnit unit (699 tests) + Magento integration (54 tests) + Playwright storefront/admin E2E (52 tests), and a GitHub Actions pipeline running syntax, PHPCS (Magento 2 + PSR-12), PHPMD, unit and integration on every push. The Playwright lane provisions a full Mage-OS instance; it runs automatically on release refs — v* tags, release/** pushes and PRs targeting release/** — and stays opt-in elsewhere via the RUN_E2E repository variable. It reports on a tag rather than gating it: a tag exists before its workflow runs, so publishing from a red tag is prevented by process, not by CI.

Security

  • A refund never carries the operator's identity. The dispatch snapshot is taken from the live request — _ga, _fbp, _fbc, remote address, User-Agent, hashed customer email. On a storefront event that is the shopper; on a credit memo raised in adminhtml days later it is the staff member, so refunds shipped an employee's advertising identifiers to GA4, Meta and TikTok as conversion data and attributed the shopper's refund to whoever processed it. Order-derived events now dispatch with an explicitly empty identifier set. Restoring the shopper's real identifiers would need a lawful order-time snapshot; sending nobody's is correct today and never wrong.
  • Stored landing and referrer URLs keep only origin and path. They persist against the order for its whole life, and storefront query strings routinely carry password-reset tokens, confirmation keys, session ids and email addresses. The campaign fields are extracted by allowlist beforehand, so dropping the query loses no attribution. Enforced server-side as well as in the browser, because the cookie is client-controlled and a forged one must not be able to write a full URL onto an order.
  • Personal data is filtered where the payload is built, not where it is sent. The order payload is handed to an admin-editable template, so anything present in it is something a template can put on the page or into an outbound request. order.customer_email and the household-level billing fields need ad_user_data consent and "Expose Raw Personal Data"; order.customer_email_hash needs the consent alone, because hashing governs how an identifier travels rather than whether a refused one may be sent; order.attribution needs ad_storage.
  • Custom variables are allowlisted, not blocklisted. Config paths must match a permitted prefix, extendable only through DI — so widening what the storefront can read is a code review rather than an admin text field. A blocklist could not catch credentials like carriers/dhl/account or a third-party client_id.
  • Ad click IDs are not stored before consent. Persisting gclid and friends is ad_storage under Consent Mode v2, so capture now waits for consent rather than writing a 90-day cookie on arrival. A capture made while consent is pending is held in memory and written only if the shopper grants it during that page view — the common case, since the banner appears on the very page carrying the click ID — so gating costs no attribution that the shopper actually consented to.
  • Raw order PII honours "Expose Raw Personal Data". Previously the setting was applied to the customer section but not to the order payload, so it silently did not cover the payload most likely to leave the site. With consent granted, the hash is what advanced matching consumes, so leaving the setting off costs no match quality.
  • Custom variables cannot read credentials. Config paths under admin, payment, crypt and smtp, and any path naming itself a password, token, key or secret, are refused before the value is read, and the refusal is logged.

Fixed

  • Order events report the order's context, not the operator's. A credit memo is raised in adminhtml, where the ambient store and currency belong to the admin user. Store::getCurrentCurrencyCode() reads the HTTP context or session, so an order paid in EUR and refunded by an operator browsing in USD was reported as USD and reconciled against nothing. Events that own an order now pass its currency explicitly, and both order observers scope their enabled check to the order's store rather than the ambient one.
  • One failing order no longer silences the rest. Both the purchase and attribution observers wrapped their whole multishipping loop in a single try/catch, so the first failure dropped every order after it — invisibly, because the success page renders only the last order. Each order is now guarded individually.
  • Tracked pages no longer start a customer session. Magento's session manager starts the session in its constructor, and UserDataCollector sits on the eager path of every tracked page — before the module's enabled check. A store with the module switched off still started a session on every full-page-cache miss, and a started session makes that response uncacheable. Now injected through a proxy.
  • Multishipping orders are reported. Magento dispatches checkout_submit_all_after with an orders array and no order key for multishipping, so observers reading the singular key ignored the entire checkout: with the success page rendering only the last order, every other order went unreported. Both observers now normalise either shape, so each order is dispatched as its own purchase and all of them receive the checkout's attribution.
  • Refund value matches the refunded items. It was derived from credit-memo totals, whose aggregate discount includes any refunded shipping discount that no item price subtracts — so the value disagreed with the items in the same payload, and diverged again whenever items hit the 200-item cap. It is now summed from the emitted rows, as purchase already was.
  • Indivisible rows keep their cents. A unit price is a row divided by a quantity and does not always land on a cent: 10.00 across three units rounded to 3.33 and multiplied back to 9.99. Emitted unit prices keep twelve decimals so price × quantity reconstructs the row, with the event value still rounded to currency precision. Twelve because that is what the schema demands, not a round number: quantity is DECIMAL(12,4) against a row total of DECIMAL(20,4), so the smallest unit price a valid row can produce is 0.0001 / 99,999,999.9999 — exactly 1.0e-12. At eight decimals a 0.01 row across 2,000,000 units reconstructed to 0.02, and across 2,000,001 units to 0.00, losing the line's revenue entirely. The excess digits cannot reach a payload: Magento pins serialize_precision to 14, so json_encode never emits them, while the stored double keeps them — which is what makes the reconstruction exact.
  • GA4 revenue and discounts. value carried the grand total while tax and shipping were also sent separately, double-counting both. Item money was wrong in two further ways: price was tax-inclusive, and it was the list price sent alongside a discount — but GA4 derives item revenue as price × quantity and never subtracts discount, so a discounted line overstated revenue by the whole discount. price is now the net price actually paid per unit, discount is per unit and reported for the discount metric only, *_incl_tax fields carry the gross equivalents, and value is summed from the emitted prices so value == sum(price × quantity) holds for every shipped template.
  • Attribution survives Magento's own cookie notice. The native notice announces acceptance with a jQuery-triggered event that a native listener never receives, so a shopper who accepted through it lost the held click ID.
  • add_to_cart reports the chosen variant of a configurable, not the parent SKU at the parent's (cheapest-variant) price, and carries item_variant matching what purchase reports for the same line.
  • A refund is reported once per credit memo. The bound event fires on every save, so adding a comment used to report a second refund.
  • A rejected payload is no longer logged as delivered. TikTok answers 200 while discarding a payload it rejects; destinations now read the response body before an attempt counts as success.
  • An unattributable visitor is skipped, not sent. TikTok requires at least one identifier to attribute an event and rejects a call carrying none. The destination now skips that case the way Meta already did, so the dispatch log records no user identifiers instead of a delivery failure that reads like a credential problem.
  • Uninstall removes data. module:uninstall --remove-data now drops all four tables and the module's config; previously there was no Uninstall class, so it dropped nothing.
  • GA4 validation is strict. qoliber:datalayer:ga4:validate sends validation_behavior=ENFORCE_RECOMMENDATIONS, so it cannot report "valid" under GA4's relaxed validation.

Compatibility

  • PHP 8.1 through 8.5. The unit suite runs clean on 8.5 with no deprecation from module code; the deprecations PHP 8.5 raises during the run are non-canonical (double) casts inside Magento core, which is also what caps a real install — mage-os/framework requires ~8.2.0||~8.3.0||~8.4.0, and Composer intersects constraints, so declaring 8.5 here cannot place the module on a PHP the platform refuses. It simply stops needing a release when Mage-OS catches up. A Rector downgrade dry-run (rector-php-compat.php) guards the 8.1 floor in CI, because forward compatibility only ever breaks from below: syntax or a stdlib call newer than 8.1 works on the version it was written on and fatals on a merchant's.

Known limitations

  • PHPStan (level 8) runs as a local gate via the in-project phpstan.neon rather than in CI: a run without Magento installed cannot resolve inherited core methods, and installing PHPStan 2.x into the Magento project conflicts with the 1.x that Mage-OS requires. Running it from an isolated working directory against the Magento autoloader is the intended fix.
Changelog — Data Layer — Marketing — Extensions | qoliber Docs