silvershop-suite / conversion-tracking
Ad conversion / event tracking core for SilverShop (CMS 6): a normalised ViewContent→AddToCart→InitiateCheckout→Purchase event model, an always-on GA4-shaped dataLayer emit, hard consent gating bound to silvershop-suite/cookieconsent, deterministic purchase event_id dedup, and a queued server-side d
Package info
github.com/silvershop-suite/silvershop-conversion-tracking
Type:silverstripe-vendormodule
pkg:composer/silvershop-suite/conversion-tracking
Requires
- php: ^8.3
- silvershop/core: ^6.2
- silverstripe/cms: ^6
- silverstripe/framework: ^6
- symbiote/silverstripe-queuedjobs: ^6
Requires (Dev)
Suggests
- silvershop-suite/catalog-data: Shared product-identity source: content_ids / item_id = CatalogProductData::feedId(), so the ad-platform catalogue match lines up with the product feed by construction. A built-in identical-rule fallback is used when absent.
- silvershop-suite/cookieconsent: The consent gate + Consent Mode v2 source every tag/event binds to (Meta → marketing, GA4 → statistics, Ads → marketing). Strongly recommended; without it the module default-denies.
- silvershop-suite/order-confirmation: Provides the unified, status-aware checkout/complete/{ID} success render the client-side Purchase fires on (paid only, never pending).
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-10-23 14:33:21 UTC
README
Ad conversion / event tracking core for SilverShop (SilverStripe CMS 6). It mints a single, normalised ecommerce event at each shop lifecycle seam — ViewContent → AddToCart → InitiateCheckout → Purchase — and fans it out:
- an always-on, GA4-shaped
dataLayerpush (so a GTM-managed shop wires its own tags and the core stays out of the way), and - each installed connector's first-party tag (Meta Pixel / Google
gtag) plus its server-to-server path (Meta Conversions API / Google Enhanced Conversions), through a retryable queued job.
Everything is hard-gated on consent (bound to silvershop-suite/cookieconsent), the browser event and the server
event share a deterministic event_id so Meta/Google de-duplicate the pair, and product ids are the shared
feed id (silvershop-suite/catalog-data), so the ad-platform catalogue match lines up with the product feed by
construction.
This module is the core. The per-platform clients ship separately:
silvershop-suite/conversion-tracking-meta— Meta Pixel + Conversions API.silvershop-suite/conversion-tracking-google— GA4 + Google Ads + Enhanced Conversions.
It mirrors the core+connector architecture of silvershop-suite/newsletter (+ its ESP adapters) and silvershop-suite/search
(+ search-meilisearch).
Requirements
- PHP ^8.3
silverstripe/framework^6,silverstripe/cms^6silvershop/core^6.2symbiote/silverstripe-queuedjobs^6 — the server-side dispatch runs as a retryable queued job
Strongly recommended / optional (all read behind class_exists guards):
silvershop-suite/cookieconsent— the consent gate + Consent Mode v2 source every tag/event binds to. Without a consent source the module default-denies (fires nothing) unless you opt in (see Consent below).silvershop-suite/catalog-data— the shared product identity (content_ids/item_id=feedId()). A built-in identical-rule fallback is used when absent.silvershop-suite/order-confirmation— the unified, status-awarecheckout/complete/{ID}success render the client-side Purchase fires on.
Install
composer require silvershop-suite/conversion-tracking
Install at least one connector and supply its env ids/token, then switch it on in config (the connectors register
themselves — see their READMEs). With no connector, the core still emits the GA4 dataLayer for a GTM shop.
How it works
The normalised event
Every seam mints a platform-neutral ConversionEvent (src/Model/):
| Field | Notes |
|---|---|
name |
EventName enum → Meta ViewContent/… and GA4 view_item/… |
eventId |
the shared client↔server dedup id; purchase.{OrderID} for Purchase |
items[] |
ConversionItem — id = feed id, itemGroupId, name, price, quantity, brand, gtin |
value / currency |
order/line value + ISO-4217 currency (get_site_currency()) |
contentType |
product / product_group |
user |
raw ConversionUser matching data — hashing/forwarding is the connector's job; never on the dataLayer |
consentedCategories |
the cookieconsent categories granted at mint, frozen on for the queued server send |
The lifecycle seams (no theme edits)
| Event | Seam |
|---|---|
ViewContent / view_item |
ProductController::onAfterInit (bare product page) |
AddToCart / add_to_cart |
Order::afterAdd — deferred: queued in session, drained on the next render (the add action redirects/AJAX-returns) |
InitiateCheckout / begin_checkout |
CheckoutPageController::updateOrderForm (checkout form render) |
| Purchase (server truth) | Order::onPaid — fires once on the paid transition (the webhook truth for async iDEAL/Mollie), enqueues the dispatch job |
| Purchase (client) | the confirmed-paid confirmation render (checkout/complete/{ID} / order), access-gated + IsPaid() only — never pending |
All tags are injected via a ContentController extension + Requirements (exactly how silvershop-suite/cookieconsent
injects) — the module never edits a theme template.
Deduplication
The browser Pixel/gtag Purchase and the queued server Purchase both carry event_id = purchase.{OrderID} (and GA4
transaction_id = {OrderID}), so Meta and Google collapse the pair into one conversion. A webhook re-send of
onPaid reuses the same id. This is the single most important correctness detail — a mismatched id double-counts
Purchase.
Product identity = the feed id
content_ids / GA4 item_id = SilverShop\CatalogData\CatalogProductDataFactory::feedId() when
silvershop-suite/catalog-data is installed (so the feed's g:id and the ad content_ids are the same string by
construction). When catalog-data is absent, a built-in fallback mints the id by the identical rule (SKU-first,
product-{ID} / {parentFeedId}-{variationID}, ≤50 chars), so the two never diverge.
Consent (bound to cookieconsent — not reinvented)
Nothing fires before consent. The module is a consumer of silvershop-suite/cookieconsent:
- PHP gate —
ConsentState::has('marketing' | 'statistics')decides whether a tag/event may fire and whether a server send runs. - Prior script-blocking — client tags are emitted through
ConsentScript::inline()/src()as inert<script type="text/plain" data-consent-category="…">, which the cookieconsent runtime activates on grant and on everyconsentchange. True ePrivacy prior opt-in: nothing fetches/fires pre-consent. - Consent Mode v2 — cookieconsent already emits
gtag('consent','default', …denied)and theupdateon a decision, mappingmarketing → ad_storage/ad_user_data/ad_personalizationandstatistics → analytics_storage. This module relies on that and does not reinvent it.
Category bindings: Meta → marketing, GA4 → statistics, Google Ads → marketing. The always-on
dataLayer push is gated on statistics by default (ConversionEventService.datalayer_consent_category).
Default-deny when there is no consent source. With silvershop-suite/cookieconsent absent, the module fires nothing
unless you explicitly accept responsibility for consent elsewhere:
SilverShop\ConversionTracking\Service\ConsentGate: assume_consent_without_module: true # default false (GDPR-safe)
Consent Mode SSR-default coordination note.
silvershop-suite/cookieconsentonly emits the server-sidegtag('consent','default', …denied)in<head>when theGA_CODEorGTM_CODEenv var is set; otherwise the default-denied is emitted by its JS runtime (always, before any tag activates). For a belt-and-suspenders SSR Consent-Mode default without relying on the JS runtime, set one of those env vars. This module does not edit cookieconsent and does not emit a competing default.
Writing a connector
Implement SilverShop\ConversionTracking\Provider\ConversionProvider (see the Meta/Google connectors) and register
it:
SilverShop\ConversionTracking\Provider\ConversionProviderRegistry: providers: meta: '%$SilverShop\ConversionTracking\Meta\MetaProvider' active: - meta
A provider turns a ConversionEvent into that platform's consent-gated client snippet(s) + base tag + its
server-side send, and declares its consentCategory() and mode() (datalayer_only | built_in_tags | both,
default both). An active-but-unconfigured provider is skipped, so the module is inert until its env is supplied.
Configuration highlights
SilverShop\ConversionTracking\Service\ConversionEventService: datalayer_consent_category: statistics # which category gates the always-on dataLayer push always_datalayer: true # emit the GA4 dataLayer regardless of connector mode server_dispatch_events: [purchase] # which events get a server-side dispatch job (v1: Purchase) SilverShop\ConversionTracking\Service\ConsentGate: assume_consent_without_module: false # fire with no consent module? (GDPR-safe default: no) SilverShop\ConversionTracking\Service\ConversionItemFactory: id_source: internal_item_id # fallback-only; mirrors catalog-data (keep aligned)
Privacy / GDPR (read before going live)
- PII (email/phone/name/address) is sent only server-side (Meta CAPI hashes it SHA-256; Google Enhanced
Conversions receives it raw and hashes client-side), and only with
marketingconsent. It is never put on the browserdataLayer. - Data is sent to Meta (US) and/or Google (US) — a processor relationship your privacy policy must declare (DPA + SCCs). State the data-sharing clearly.
_fbp/_fbcare marketing cookies — declare them under cookieconsent'smarketingcategory.- cookieconsent already writes immutable consent-proof records; no extra consent logging is added here.
Development
composer install composer lint # PHP_CodeSniffer (PSR-12) composer stan # PHPStan (silverstan, level 5) composer test # PHPUnit
Licence
BSD-3-Clause.
Credits
Built and maintained by Remy Vaartjes (remy@xd.nl) — part of the SilverShop-Suite.