tattali/utm-cookie-bundle
tattali/utm-cookie-bundle captures UTM parameters from incoming URLs and stores them in cookies, so your app can attribute visits, signups, or conversions to the right campaign long after the first click. Simple setup and configurable cookie lifetime for PHP apps.
First, the important framing: tattali/utm-cookie-bundle is a Symfony bundle, not a Laravel package. It ships no service provider, no facade, no publishable config, and no package auto-discovery. composer require tattali/utm-cookie-bundle inside a Laravel app will install the files but nothing will wire itself up — no request listener runs, no cookie is ever written. Treat this as a reference implementation to read and port (it is small — a request listener, a service, a Twig extension, and a DI extension), not as a drop-in dependency.
That said, reading it is a good 20-minute exercise, because the problem it solves is one every marketing-facing Laravel app has.
Where to look first, in order:
kernel.request — this is the entire capture mechanism. It reads the query string, filters to known utm_* keys, and sets a cookie.Configuration class — this is where the cookie name, lifetime, and captured key list are defined. Version drift lives here.Minimal Symfony setup (if you're actually in a Symfony app):
// config/bundles.php
return [
// ...
Tattali\UtmCookieBundle\TattaliUtmCookieBundle::class => ['all' => true],
];
# config/packages/tattali_utm_cookie.yaml
tattali_utm_cookie:
cookie_name: utm # verify exact key names against Configuration.php
cookie_duration: 30 # days — check whether this is days, seconds, or a date modifier
First use case: a paid or email campaign lands on https://app.test/?utm_source=newsletter&utm_medium=email&utm_campaign=spring. The listener writes those values to a cookie. Three pages later the user registers; you read the cookie and stamp utm_source, utm_medium, utm_campaign onto the users or leads row. That's it — that's the whole package.
Minimal Laravel equivalent, if you want the behavior today without waiting on a port:
// app/Http/Middleware/CaptureUtm.php
public function handle(Request $request, Closure $next)
{
$keys = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content'];
$utm = array_filter($request->only($keys));
if ($utm !== []) {
cookie()->queue(config('marketing.utm_cookie', 'utm'), json_encode($utm), 60 * 24 * 30);
}
return $next($request);
}
Read it back with json_decode($request->cookie('utm', '{}'), true).
Read-all at conversion time. The dominant pattern is: capture passively on every request, read once at the moment of value. Inject the service into your registration/checkout controller and copy the full array onto the record. Don't read the cookie all over the codebase — one read point keeps attribution consistent and testable.
Read-one for hidden form fields. The Twig extension exists so templates can drop attribution into a form without a controller round-trip:
<input type="hidden" name="utm_source" value="{{ utm_cookie('utm_source') }}">
In Laravel, the equivalent is a view composer or View::share so the values are available to every Blade view:
View::composer('*', function ($view) use ($request) {
$view->with('utm', json_decode($request->cookie('utm', '{}'), true));
});
First-touch vs. last-touch is a product decision, not a code detail. The bundle writes on every UTM-bearing request, which means last-touch wins — an email click overwrites the Google Ads click that got the user there. If marketing wants first-touch, guard the write:
if ($utm !== [] && ! $request->hasCookie('utm')) {
cookie()->queue('utm', json_encode($utm), 60 * 24 * 30);
}
Decide this before launch. Retrofitting it later means your historical attribution data is inconsistent.
Attribute to the entity, not the session. Cookies expire, users switch devices, and consent banners delete them. The durable pattern is: cookie → hidden form field → leads.utm_source / orders.utm_json column on first meaningful action. After that, read from the database, never from the cookie.
Inertia / Livewire / SPA note. If you're server-rendering JSON props, share the UTM array as a page prop on first load only, then persist it in the payload of the conversion request. Don't expect the cookie to be readable from JavaScript unless you deliberately excluded it from HttpOnly.
Extension points. In Symfony, the intended extension points are: decorating the UTM service to add keys (e.g. gclid, fbclid, msclkid), or replacing the listener. In a Laravel port, the equivalent is making the key list config-driven rather than hardcoded — the bundle's fixed list is its main functional limitation.
It will silently do nothing in Laravel. No bundle registration, no listener, no cookie. This is the single biggest trap — you'll see the class exists, assume it works, and lose a week of attribution data before someone notices the Set-Cookie header is missing. If you install it in a Laravel app, you're porting it. Budget an afternoon and write tests.
Consent and GDPR. This bundle sets a cookie on the first request, before any consent interaction. In the EU that is a compliance problem, not a style preference. If you have a consent manager, gate the write behind it — which, in practice, means your own middleware anyway.
"Why is the cookie empty on the same request?" The capture listener reads the query string; the cookie is only visible on subsequent requests. In Laravel, cookie()->queue() writes to the response — $request->cookie('utm') will be null on the landing request itself. Always fall back to $request->only($keys) when reading on the first hit:
$utm = array_filter($request->only($keys)) ?: json_decode($request->cookie('utm', '{}'), true);
Only utm_* is captured. Click IDs (gclid, fbclid, ttclid, msclkid) and custom params are ignored by default. For paid social and Google Ads these are frequently more valuable than the UTM triplet. Extend the key list deliberately.
Cookie attributes need review. Domain, path, Secure, SameSite, and HttpOnly all matter here. Defaults often give you a host-only cookie that silently fails to travel between www. and app. subdomains. Check the Set-Cookie header in devtools on the landing request — that's the only source of truth. If the header looks right and the value is still missing, you almost certainly have a domain/path mismatch.
Config key names and units drift between versions. The repo has effectively zero community scrutiny (no dependents, no stars), so treat the README as aspirational. Read DependencyInjection/Configuration.php and use exactly what's there — in particular, confirm whether the expiration value is days, seconds, or a strtotime modifier.
Debugging checklist, in order: (1) does the landing URL actually contain the params? (2) does the response have a Set-Cookie? (3) does the next request send it back? (4) is the cookie name colliding with something else? Each step isolates a different layer, and skipping to step 4 wastes time.
Vendor it, don't depend on it. Given the near-total absence of adoption and the tiny surface area, the sane move is to copy the listener logic into your own middleware, keep the behavior you need, and drop the dependency. You get the extension points the bundle lacks and you don't inherit its maintenance risk.
How can I help you explore Laravel packages today?