English | Português | Deutsch | 中文
clicktrail/psr-middleware
PSR-15 middleware for ClickTrail attribution in any PSR-7 framework (Slim, Mezzio, Laminas, custom) — deterministic first/last touch on the request, nothing written without consent.
- Why
- Installation
- Quick start
- Storage adapters
- Consent
- Reading the context
- How it differs
- Testing
- License
Most attribution middleware captures UTMs into a cookie and calls it done — no merge law, no consent gate, storage decisions baked in. This package runs the deterministic ClickTrail core inside your PSR-15 stack and hands your handlers an immutable AttributionContext: merged touches, resolved consent, and an audit trail of everything that was suppressed and why. Part of the ClickTrail PHP/Twig expansion (ADR-0001 polyrepo, layer 1).
composer require clicktrail/psr-middlewareuse ClickTrail\Middleware\ArrayStore;
use ClickTrail\Middleware\CaptureAttributionMiddleware as Capture;
use ClickTrail\Middleware\ConsentMiddleware;
use ClickTrail\Middleware\CookieStore;
use ClickTrail\Middleware\NullConsentResolver;
$clock = fn (): string => (new DateTimeImmutable('now', new DateTimeZone('UTC')))
->format('Y-m-d\TH:i:s.v\Z');
$app->add(new ConsentMiddleware($myCmpAdapter)); // optional; resolves the snapshot once
$app->add(new Capture(
store: new CookieStore('ct_attr'), // or ArrayStore / your session impl
consentResolver: new NullConsentResolver(), // swap in your CMP adapter
clock: $clock,
));
// downstream handler/controller:
$context = $request->getAttribute(Capture::DEFAULT_ATTRIBUTE);
$context->firstTouch(); // ?Touch - original acquisition, untouched by later direct visits
$context->lastTouch(); // ?Touch - most recent signal
$context->canPersist(); // true only when consent allowed the storage write
$context->suppressionReasons; // audit trail of what was blocked and whyA paid-search hit followed by a direct visit leaves firstTouch() unchanged while lastTouch() moves — that is the merge law of the shared core, not this package's opinion. Without a consent grant, no store write happens at all.
The middleware never decides where state lives. Implement StateStoreInterface (session, database, cache) or ship one of the built-ins:
ArrayStore— per-request memory. Tests and stateless workers.CookieStore— cookie-backed persistence, e.g.new CookieStore('ct_attr').
If the injected ConsentResolverInterface returns no grant, StateStoreInterface::save() is never called — no cookie, no session entry, nothing.
A null snapshot means unknown, which is denied by default per the consent compatibility contract. Two ways to wire it:
- Pass a
consentResolvertoCaptureAttributionMiddleware; it gates persistence directly. - Add
ConsentMiddlewareupstream; it resolves the snapshot once per request and attaches it under its own attribute (clicktrail.consent) for anything else downstream.
NullConsentResolver is the safe default: every persistence attempt becomes a recorded suppression reason instead of a write.
AttributionContext is an immutable value object attached to the request (Capture::DEFAULT_ATTRIBUTE, override via the attribute constructor argument):
$context->attribution; // StoredState - full merged first/last touch state
$context->consent; // ?ConsentSnapshot - null when unknown
$context->persisted; // bool - did this request actually persist?
$context->suppressionReasons; // string[] - human-readable audit entriesSnapshots travel with the lead, so months later the conversion worker knows exactly which permissions existed at capture.
| Typical tracking middleware | clicktrail/psr-middleware |
|---|---|
| Makes remote calls during the request cycle | No remote calls, ever — event delivery belongs to clicktrail/php-sdk |
| Reads wall-clock time itself | Injected clock callable returning ISO-8601 millisecond timestamps |
| Writes cookies, then asks about consent | No grant → no save() call → no write |
| Bundles its own storage backend | Storage belongs to the adapter: bring StateStoreInterface, or use ArrayStore / CookieStore |
podman run --rm -v "$PWD:/app" -v "$PWD/../clicktrail-php:/sdk:ro" \
wordpress:php8.3-apache php /app/tests/_runner.phpMIT © 2026 Vizuh OÜ