clicktrail/psr-middleware

PSR-15 middleware for ClickTrail attribution: capture UTMs/click IDs from incoming requests, preserve first/last touch, persist only when consent permits, attach context downstream. No remote calls during the request.

Maintainers

Package info

github.com/vizuh/clicktrail-psr-middleware

pkg:composer/clicktrail/psr-middleware

Transparency log

Statistics

Installs: 0

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 1

v0.1.0 2026-08-24 22:59 UTC

This package is auto-updated.

Last update: 2026-08-25 12:16:56 UTC


README

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.

CI License: MIT

Index

Why

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).

Installation

composer require clicktrail/psr-middleware

Quick start

use 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 why

A 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.

Storage adapters

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.

Consent

A null snapshot means unknown, which is denied by default per the consent compatibility contract. Two ways to wire it:

  • Pass a consentResolver to CaptureAttributionMiddleware; it gates persistence directly.
  • Add ConsentMiddleware upstream; 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.

Reading the context

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 entries

Snapshots travel with the lead, so months later the conversion worker knows exactly which permissions existed at capture.

How it differs

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

Testing

podman run --rm -v "$PWD:/app" -v "$PWD/../clicktrail-php:/sdk:ro" \
  wordpress:php8.3-apache php /app/tests/_runner.php

License

MIT © 2026 Vizuh OÜ