rasuvaeff / yii3-utm
UTM capture and attribution for Yii3: touchpoint history in one cookie, click-id support, consent-gated middleware and an append-only attribution journal
Requires
- php: 8.3 - 8.5
- ext-json: *
- ext-mbstring: *
- psr/clock: ^1.0
- psr/http-message: ^2.0
- psr/http-server-handler: ^1.0
- psr/http-server-middleware: ^1.0
- yiisoft/cookies: ^1.2.3
Requires (Dev)
- ergebnis/composer-normalize: ^2.51
- friendsofphp/php-cs-fixer: ^3.95
- infection/infection: ^0.33
- maglnet/composer-require-checker: ^4.17
- nyholm/psr7: ^1.8
- rasuvaeff/property-testing: ^2.8
- rector/rector: ^2.4
- roave/backward-compatibility-check: ^8.0
- testo/bridge-infection: ^0.1.6
- testo/testo: ^0.10.25
- vimeo/psalm: ^6.16
- yiisoft/di: ^1.2.1
- yiisoft/test-support: ^3.0
This package is auto-updated.
Last update: 2026-08-03 16:14:26 UTC
README
Marketing attribution for Yii3 applications: capture UTM parameters, click identifiers and referrers, keep a short touchpoint history, and record an append-only attribution journal for registrations, purchases and any other business event.
Using an AI coding assistant? llms.txt is a compact API reference written for LLMs. The package also ships an agent skill through llm/skills.
Status: feature-complete. Use rasuvaeff/yii3-utm-db for portable
yiisoft/db persistence, provide an application repository, or use the
shipped in-memory implementation in tests.
Requirements
- PHP 8.3 – 8.5
ext-json,ext-mbstring
Installation
composer require rasuvaeff/yii3-utm
Why a journal and not a column
Between an ad click and a purchase there are days and several visits. A single "current UTM" column answers the wrong question. This package keeps the last touchpoints and, when a business event happens, writes one row per touchpoint, so first-touch, last-touch and multi-touch models are all answerable later.
Three invariants shape the whole API:
- Everything the client sends — query, headers, body, cookies,
localStorage— is untrusted. Values are normalised, truncated or dropped, never authenticated. - The journal is append-only and ordered by the server. A client cannot make a late delivery become the first touch.
- Deduplication happens per touchpoint within one business event, so a retry of the same event writes nothing new while a genuinely new event does.
Usage
Campaign parameters
UtmParameters is the campaign tuple: the five standard utm_* fields plus
GA4 utm_id. Factories normalise untrusted input — control characters are
stripped, values trimmed and truncated to 255 characters, empty strings become
null.
use Rasuvaeff\Yii3Utm\UtmParameters; $utm = UtmParameters::fromArray($request->getQueryParams()); $utm->source; // 'google' $utm->content; // 'banner-a' — mapped to content, not campaign $utm->isEmpty(); // false $utm->toArray(); // stable snake_case keys, round-trip safe
Click identifiers
Auto-tagging platforms attach a click id and no utm_* at all — Google Ads
sends a bare gclid. ClickIds accepts only whitelisted keys, in whitelist
order, and caps its serialised length at the storage column width.
use Rasuvaeff\Yii3Utm\ClickIds; $ids = ClickIds::fromArray($request->getQueryParams()); $ids->get('gclid'); // 'EAIaIQobChMI...' $ids->isEmpty(); // false $ids->toJson(); // {"gclid":"..."} — deterministic key order
Supported keys: gclid, gbraid, wbraid, fbclid, yclid, ttclid,
msclkid, li_fat_id, twclid.
Touchpoints and history
A UtmTouchpoint is one contact: campaign tuple, click ids, referrer, landing
page and the timestamp the source claims. UtmHistory keeps them newest first.
use Rasuvaeff\Yii3Utm\{Referrer, UtmHistory, UtmSimilarity, UtmTouchpoint}; $touchpoint = UtmTouchpoint::of( utm: $utm, occurredAt: new DateTimeImmutable('now', new DateTimeZone('UTC')), clickIds: $ids, referrer: Referrer::of('https://ads.example.com/'), landingPage: 'https://shop.example.com/summer', ); $history = UtmHistory::of($touchpoint) ->deduplicated(UtmSimilarity::Campaign) // keeps the oldest of each group ->limited(5); // keeps the newest five $history->latest(); $history->oldest();
| Method | Behaviour |
|---|---|
UtmHistory::of(...$touchpoints) |
Sorts newest first; ties broken deterministically |
with(UtmTouchpoint) |
Returns a new history with the touchpoint added |
deduplicated(UtmSimilarity) |
Collapses similar touchpoints, keeping the oldest of each group |
limited(int) |
Keeps at most N newest touchpoints |
latest() / oldest() / all() / count() / isEmpty() |
Read accessors |
UtmSimilarity decides what "similar" means: Full (campaign tuple and click
ids), Campaign (source, medium, campaign) or SourceMedium.
Interaction types
Which business events exist is the application's decision, so the type is a validated string, not an enum:
use Rasuvaeff\Yii3Utm\InteractionType; InteractionType::registration(); InteractionType::purchase(); InteractionType::of('trial_started'); // /^[a-z][a-z0-9_]{0,31}\z/
Channel classification
Channel is derived on read and deliberately not stored — classification rules
change more often than a major release allows.
use Rasuvaeff\Yii3Utm\{Channel, DefaultChannelResolver}; $channel = (new DefaultChannelResolver())->resolve($touchpoint); // Channel::Paid — a click id outranks everything else
Rule order: click id → utm_medium → referrer host. Vocabularies (paid, email
and social mediums, social and search hosts) are constructor arguments.
Capture
One middleware; the transports it understands are configuration, not separate classes.
use Rasuvaeff\Yii3Utm\UtmCaptureMiddleware; // web pipeline UtmCaptureMiddleware::class,
use Rasuvaeff\Yii3Utm\UtmRequest; UtmRequest::current($request); // ?UtmTouchpoint — carried by this request UtmRequest::history($request); // UtmHistory — stored, may be empty UtmRequest::effective($request); // ?UtmTouchpoint — current ?? newest stored
The attributes are always set, so downstream code never distinguishes "the middleware did not run" from "nothing was captured".
| Transport | Source | Use for |
|---|---|---|
| Query string | QueryUtmSource |
Server-rendered pages; landing page and Referer are captured too |
X-Utm-* headers |
HeaderUtmSource |
SPA and API clients; click ids use JSON in X-Utm-Click-Ids |
Nested utm body key |
BodyUtmSource |
SPA and API; the recommended cross-domain transport |
All three sources drop a referrer that matches the current request's own host
(Referrer::external(), not Referrer::of()): navigating from one page of
your site to another is not a touchpoint to attribute the visit to.
History lives in a single cookie (utm_history by default) encoded by
UtmCookieCodec: HttpOnly, Secure, SameSite=Lax, 30 days. A client
profile (httpOnly: false) exists for same-domain SPA reads and is spoofable by
definition. DefaultLandingPageSanitizer — the shipped implementation — keeps scheme, host,
port and path, drops the fragment and every query parameter outside its
allow-list (utm_* and click ids by default), and truncates to 500 characters.
NullUtmHistoryStore stores nothing — the right choice for
stateless APIs and cacheable routes, since capture otherwise adds a
Set-Cookie header and makes a response uncacheable.
| Option | Default | Effect |
|---|---|---|
enabled |
true |
Master switch |
ignoredPaths |
[] |
Path prefixes to skip |
similarity |
Full |
What counts as "the same campaign" |
updateExisting |
false |
Whether a touchpoint similar to the newest stored one is appended |
captureOrganic |
false |
Whether a visit with neither campaign nor click id becomes a touchpoint |
maxTouchpoints |
5 |
History cap |
maxTouchpointAge |
90 days | Window a claimed occurredAt is clamped into |
clearHistoryWithoutConsent |
false |
Whether a stored history is expired when consent is absent |
Consent
ConsentPolicy::allowsPersistence() gates the whole thing: without consent
nothing is read and nothing is written. The default is AllowAllConsentPolicy
— for applications where consent is enforced earlier in the stack.
use Rasuvaeff\Yii3Utm\CallbackConsentPolicy; new CallbackConsentPolicy( static fn (ServerRequestInterface $r): bool => $consentBanner->accepted($r), );
The method name matches rasuvaeff/yii3-ab-testing-web, so an application that
already has a policy reuses it in one line.
Configuration
The package ships config/di.php and config/params.php for
yiisoft/config. It binds the capture stack, the codec, the sanitizer, the
channel resolver and the consent default — and deliberately not
UtmAttributionRepository, which must come from exactly one source.
The rasuvaeff/yii3-utm params group exposes:
capture.sources.query.utmKeysandclickIdKeys;capture.sources.header.prefixandclickIdKeys;capture.sources.body.keyandclickIdKeys;sanitizer.allowedQueryKeysandmaxLength;channel.paidMediums,emailMediums,socialMediums,socialHostsandsearchHosts.
Attribution
A business event becomes one row per touchpoint. UtmAttribution derives its
own fingerprint and dedupeKey — they are never constructor arguments,
because a mismatched fingerprint would silently defeat the unique index of the
journal.
use Rasuvaeff\Yii3Utm\{InteractionType, UtmAttributionEvent, UtmAttributionService}; $service = new UtmAttributionService($repository); // repository comes from -db or the app $service->record(new UtmAttributionEvent( entityId: (string) $user->getId(), eventId: $order->getUuid(), // stable across retries, new for a new event interactionType: InteractionType::purchase(), history: $history, )); // returns the number of rows actually created
| Guarantee | Detail |
|---|---|
| Retry of the same event | Writes nothing: deduplication is keyed by event id and touchpoint |
| A genuinely new event | Writes rows even for an identical campaign |
| Partial write | Self-healing — redelivery adds what is missing and duplicates nothing, which is why no transaction wraps the batch |
| Order | Oldest touchpoint first; server assigns the canonical order at write time |
| Empty touchpoints | Skipped — a row attributing nothing is noise |
UtmAttributionEventHandler is a ready listener (__invoke), but the package
does not subscribe it: wiring is the application's decision.
Storage
UtmAttributionRepository is the storage contract — append(),
findByEntity(), findFirst(), findLast(), countByEntity(),
deleteByEntity(), purgeOlderThan() and countOlderThan() (what
purgeOlderThan() would remove, without removing it — for a dry run). The
core does not bind it: an
implementation comes from rasuvaeff/yii3-utm-db or from the application.
InMemoryUtmAttributionRepository is shipped for tests and returns
InMemoryUtmAttributionRecord instances; it is never bound.
Implementations must make append() race-safe — an upsert that does nothing on
conflict, or an insert whose duplicate-key error is handled. "Check, then
insert" is not enough.
Security
| Aspect | Behaviour |
|---|---|
| Client input | Untrusted: normalised, truncated, invalid values become null |
occurredAt |
A claim by the source, never proof of when a visit happened |
| Ordering | Server-assigned; a late delivery cannot become the first touch |
| Deduplication | Fingerprint and dedupe key are derived, never accepted from callers |
| Referrer | Only its host takes part in the fingerprint; sanitising URLs is the capture layer's job |
| Landing page | Truncated to 500 characters; query sanitisation is applied before storage |
Examples
Runnable scripts live in examples/.
Development
make build # full gate: validate, normalize, require-checker, cs, psalm, test make cs-fix make psalm make test make test-coverage make mutation make release-check
Without Make, run the same targets through Docker — see AGENTS.md.
License
BSD-3-Clause. See LICENSE.md.