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 noadd_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_dispatchcron from the newqoliber_datalayer_dispatch_retrytable. The Dispatch Log records the real attempt number. - Cancelled orders send a server-side
refundfor the cancelled part (event idref-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
purchaseclient-side: the multishipping success page pushes onepurchaseper order. view_item_liston search results (listsearch_results), which also enablesselect_itemthere on Luma and Hyvä.- Items in
view_cart,begin_checkout,add_shipping_info,add_payment_info,purchaseandrefundcarryitem_category…item_category5, resolved in batch. - GA4 hits carry
session_idfrom the_ga_<measurement id>cookie; Meta events carryevent_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 acontext_providerlayout argument onBlock\PageEvents. refund.adjustmenttemplate variable.
Changed
- The GA4 destination takes only
purchaseandrefundby default. The Measurement Protocol cannot deduplicate, soadd_to_cart/remove_from_cartsent 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
priceis net of tax and after the line's discount, in the event's currency.add_to_cart,remove_from_cart,view_itemandview_item_listnow match checkout andpurchase;purchase/refundshippingis net of tax (taxalready 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 uncachedqoliber_datalayer/debug/accessendpoint. destination:testno 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
PUTof a template is a partial update;POSTwithoutis_enabledcreates an enabled template;getListcan filter onstore_id/store_ids. - The dedup ledger moved from per-tab
sessionStorage(qdl-pushed) to a sharedlocalStorageentry (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
loginfrom the admin's device; registering sent bothsign_upandlogin, andsign_uplacked 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_itemafterview_item_list).refunddoubled the quantity of configurable products and reported dynamic bundles inconsistently withpurchase; itsvalueignored adjustment refunds and fees.add_to_cartreported 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_listbrand 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,updateItemOptionsand 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
InstallNewEventTemplatespatch could recreate default templates a merchant had deleted. - The template form data source had no ACL resource.
module:uninstall --remove-dataleft queued dispatch messages andpatch_listrows, so a reinstall came back with no templates.etc/db_schema_whitelist.jsonwas missingqoliber_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. EventDispatchServiceswallowed\Exceptionbut 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.csvcovers 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 inqoliber_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 shippedpurchasetemplate 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 deterministicord-<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 itspurchaseevent 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/djsonengine), 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_infoandadd_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, beforepurchase. 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 embeddedview_item_listpayload 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-itemdiscountandindex. Optional fields are wrapped in@djson ifso nulls are omitted rather than emitted. - Custom/dynamic variables: a
confignamespace exposing admin-mapped store-config values to every template ({{config.name}}), and per-item product attributes underitem.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-datalayercustomer-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_idpixel dedup, SHA-256-hashed identifiers), TikTok Events API; per-attempt dispatch log with configurable retention + nightly purge cron. Therefundevent is server-side only, fired from credit-memo creation with a deterministicref-<increment_id>ID. - Consent layer: Google Consent Mode v2 defaults per store view,
qdl_consentcookie contract, replaceableConsentStateProviderInterface, 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) andqoliber: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 (seedocs/VALIDATION.md). - Storefront debug panel: an opt-in floating overlay listing every
window.dataLayerpush, for development and QA. Off by default. - Extension points: provider/destination/function DI pools, the
qoliber_datalayer_event_collectedpayload-mutation event,qoliber_datalayer_dispatch_before/qoliber_datalayer_dispatch_afteraround every server-side delivery attempt, and a replaceable consent provider preference. - Dedicated
qoliber_datalayercache 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 targetingrelease/**— and stays opt-in elsewhere via theRUN_E2Erepository 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_emailand the household-level billing fields needad_user_dataconsent and "Expose Raw Personal Data";order.customer_email_hashneeds the consent alone, because hashing governs how an identifier travels rather than whether a refused one may be sent;order.attributionneedsad_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/accountor a third-partyclient_id. - Ad click IDs are not stored before consent. Persisting
gclidand friends isad_storageunder 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,cryptandsmtp, 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
UserDataCollectorsits 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_afterwith anordersarray and noorderkey 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 ownpurchaseand 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
purchasealready 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 ofDECIMAL(20,4), so the smallest unit price a valid row can produce is0.0001 / 99,999,999.9999— exactly1.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 pinsserialize_precisionto 14, sojson_encodenever emits them, while the stored double keeps them — which is what makes the reconstruction exact. - GA4 revenue and discounts.
valuecarried the grand total whiletaxandshippingwere also sent separately, double-counting both. Item money was wrong in two further ways:pricewas tax-inclusive, and it was the list price sent alongside adiscount— but GA4 derives item revenue asprice × quantityand never subtractsdiscount, so a discounted line overstated revenue by the whole discount.priceis now the net price actually paid per unit,discountis per unit and reported for the discount metric only,*_incl_taxfields carry the gross equivalents, andvalueis summed from the emitted prices sovalue == 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_cartreports the chosen variant of a configurable, not the parent SKU at the parent's (cheapest-variant) price, and carriesitem_variantmatching whatpurchasereports 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 identifiersinstead of a delivery failure that reads like a credential problem. - Uninstall removes data.
module:uninstall --remove-datanow drops all four tables and the module's config; previously there was noUninstallclass, so it dropped nothing. - GA4 validation is strict.
qoliber:datalayer:ga4:validatesendsvalidation_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/frameworkrequires~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.neonrather 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.