exs/lander-tracking-house-bundle
Symfony bundle that detects tracking parameters in incoming requests, stores them in cookies, and appends them to any URL through Twig filters such as appendTracking and getTracking. Includes built-in extractors for cmp, cup, cu, exid, and visit parameters, with configurable default values.
First, the reality check. exs/lander-tracking-house-bundle is a Symfony bundle — it registers via AppKernel::registerBundles(), reads YAML config under exs_lander_tracking_house, and ships a Twig extension. It will not install or run in Laravel as-is. Do not composer require it into a Laravel app expecting it to work. Treat it as a reference implementation of a ~150-line pattern you re-implement.
Where to look first (in this order):
Service/TrackingParameterManager/ — the three extractor interfaces (TrackingParameterQueryExtracterInterface, TrackingParameterCookieExtracterInterface, TrackingParameterInitializerInterface) and TrackingParameterFormatterInterface. This is the entire mental model.CmpTrackingParameterExtracter, ExidTrackingParameterExtracter, CuTrackingParameterExtracter, etc.) — they show exactly how cup/cu/cuv compound strings are split.appendTracking filter — it shows how formatters are keyed by name and how {cmp}/{exid}/{visit} placeholders are substituted.exs_tracking.parameter_extracter, exs_tracking.parameter_formatter) get collected.The core flow to replicate:
Request ──▶ Extractors (query / cookie / initializer)
│
▼
merged ParameterBag ──▶ written to cookies (one per returned key)
│
▼
Formatters (name-keyed) ──▶ appendTracking(url, formatterName)
│
▼
Outbound URL with query params + {placeholder} substitution
First use case: a landing page that receives ?cup=cmp1~exid42~99, persists cmp/exid/product_id to cookies, and re-appends cu=cmp1~exid42 to links going to your app.
Minimal Laravel shape to build:
// A single value object that holds the tracking bag
class TrackingParameters extends \Illuminate\Support\Collection {}
// Middleware: capture + persist
class CaptureTrackingParameters
{
public function handle($request, Closure $next)
{
$bag = app(TrackingManager::class)->extract($request);
app(TrackingManager::class)->persist($bag, response()); // cookies
return $next($request); // read the bag back in views via a helper
}
}
Once you have a TrackingManager with an extract(), persist(), and format() method, you have the whole bundle.
1. Middleware for capture, a singleton for state. Symfony's bundle works because the extractors run once per request and the result is reused by the Twig filter. In Laravel, capture in middleware and bind the resulting bag as a scoped singleton so views/controllers can reuse it:
$this->app->scoped(TrackingParameters::class, fn () => new TrackingParameters());
2. Tagged bindings mirror the compiler pass.
The bundle's extensibility comes from exs_tracking.parameter_extracter tags. In Laravel, use a config-driven list plus app()->tagged():
// config/tracking.php
'extracters' => [
\App\Tracking\Extracters\CupExtracter::class,
\App\Tracking\Extracters\ExidExtracter::class,
\App\Tracking\Extracters\VisitExtracter::class,
],
'formatters' => [
'cu' => \App\Tracking\Formatters\CuFormatter::class,
'cuv' => \App\Tracking\Formatters\CuvFormatter::class,
],
3. Compound-string extractors — copy them verbatim.
These are the only genuinely non-obvious logic in the package. cup parses {cmp}~{exid}~{product_id}, cuv parses {cmp}~{exid}~{visit}, etc. Port the split-and-map logic directly, and make the separator configurable (~ by default) so you can adapt to affiliate networks that use _ or -:
private function parseCompound(string $raw, array $keys): array
{
$parts = explode(config('tracking.separator', '~'), $raw);
if (count($parts) !== count($keys)) {
return []; // reject malformed rather than guessing
}
return array_combine($keys, array_map('trim', $parts));
}
4. Formatters as a keyed strategy.
The appendTracking('foo') formatter name is just a lookup. In Laravel, a __call-style macro on UrlGenerator or a plain helper works:
Blade::directive('tracking', fn ($expr) => "<?php echo app(TrackingManager::class)->append($expr); ?>");
// or a fluent helper:
url()->to('/checkout')->appendTracking('cu');
5. Placeholder replacement is a second, independent feature.
appendTracking on https://foo/bar?cmp={cmp}&exid={exid} is doing text substitution, not query-param assembly. Keep them separate — don't overload one method to do both. In Laravel use strtr() with a map, not str_replace(), so a param containing {cmp} isn't double-substituted.
6. Defaults via config, applied only at init.
The bundle's initializer extractors fill cmp/exid/visit when nothing is present. Model this as a final "fill defaults" pass after query and cookie extraction — never as a formatter-time fallback, or you'll re-append defaults to URLs where the visitor never actually arrived with a campaign.
7. Cookie writing is the persistence layer.
Every key returned by an extractor becomes a cookie with that name. In Laravel, batch this with Cookie::queue():
foreach ($bag as $name => $value) {
Cookie::queue(cookie($name, $value, config('tracking.ttl'), '/', config('tracking.domain'), true, true, false, 'lax'));
}
The Laravel/Symfony mismatch is the gotcha. Everything else is small. Budget a half-day for the port, not a sprint. The bundle's README says appendParameter in the "Adding a formatter" example but appendTracking everywhere else — a real inconsistency in the source docs; don't trust the copy-paste examples.
Documented typo: the priority tag example reads priotiry: 100. If you're copying the YAML, that's how it's spelled in the README. Laravel's tagged() doesn't support priorities the same way, so encode ordering as array order in config.
Every returned key becomes a cookie. This is stated in the README ("All keys from the array returned...") and is easy to miss. If a custom extractor returns ['_token' => ...], you have just invented a cookie. Prefix your tracking cookies and validate names against an allowlist before writing.
Cookies are written without consent handling. The package has zero discussion of GDPR/CCPA/ePrivacy, SameSite, Secure, HttpOnly, or lifetime. You are inheriting an attribution mechanism that sets cookies on inbound requests. Before shipping in the EU/UK, gate persistence behind a consent check and set SameSite=Lax; Secure at minimum. This is the single biggest reason to port rather than depend.
Cookie collision with your app's own cookies. visit, cmp, exid are generic names. If any other part of your app uses a visit cookie, you'll silently clobber it. Namespace with trk_ or document the reserved names.
Extractor ordering matters. If cup and cu both arrive in the same request, whichever runs second can overwrite the first's cmp/exid. Define precedence explicitly (compound cup/cuvp first, then cu/cuv, then bare exid/visit), and short-circuit once a compound value is accepted.
Compound parsing rejects or silently truncates. explode('~', 'cmp~exid') for a cup (which expects three parts) yields a two-element array; if you array_combine with three keys it throws. Decide explicitly: reject the whole param, or fill missing with null and let initializers backfill. Don't let it 500 the landing page.
Placeholder replacement only fires for known tokens. {cmp}, {exid}, {visit} are the three the README documents. If a marketer writes {product_id} into a URL, it passes through literally. Use strtr() with a map of exactly the keys you support, and add a test asserting unknown {...} tokens survive untouched (or decide to strip them).
exid has aliases. The README says exid also reads u and uuid from the request, "first match" wins. That's undocumented precedence beyond a sentence. Pin it down in your port — query param order is not guaranteed.
Empty/whitespace compound values. cup=~exid~99 should not produce a cmp of empty string and a cookie set to "". Trim and null-check each segment; treat all-empty as no-op.
Debugging. In the Symfony original, dump the merged ParameterBag in the Twig filter to see what formatters receive. In your port, add a dedicated middleware that logs the extracted bag at debug level and inspect Set-Cookie headers in the response — most "tracking doesn't work" reports are either a cookie never set (extractor returned empty) or a cookie overwritten by a later extractor.
Extension points worth keeping. The three-interface split (query / cookie / initializer) is genuinely good design and worth preserving in your port — it's what lets you add a new network's params without touching the manager. Keep the formatter-as-strategy pattern too, because it's how marketing will ask you to "add one more param to links" without a code review of the URL-building logic.
When to escalate. If the ask grows to cross-device attribution, event-based tracking, or ad-platform integration, stop porting — the bundle never addressed those and a purpose-built tool (or a small internal CDP) is the correct answer. This package covers only the first-party, cookie-based, single-tab-journey case.
How can I help you explore Laravel packages today?