Symfony analytics without cookies or a consent banner
Cookieless analytics in a Symfony 6.4/7.x app: the Twig function, server-side conversions, and the Turbo placement trap that multiplies pageviews.
Contents
You go looking for a way to measure traffic on a Symfony app, and the first page of results is wall-to-wall consent-banner tutorials: wire up a CMP, plug in Google Analytics consent mode, handle the refusals, persist the choice. In every one of those guides the banner is a starting point, never a result. But a result is exactly what it is. If you need a banner, it’s because the tool you picked drops a cookie and ships data outside the European Union. Change the tool and the banner goes with it — and your numbers stop describing only part of your traffic.
This guide walks through the full integration on Symfony 6.4 / 7.x: the bundle, the Twig function, conversion tracking from PHP, and a placement trap that quietly multiplies your pageviews as soon as Turbo is in the picture.
Why cookieless measurement needs no banner
Consent isn’t triggered by “analytics” as a category. It’s triggered by writing to or reading from the visitor’s device — a cookie, an identifier in local storage, a browser fingerprint. Measurement that writes nothing to the browser and never rebuilds a stable identifier falls outside that scope. No advertising profile gets assembled, no visitor is recognised from one visit to the next. The second source of friction, transferring personal data to servers outside the EU, disappears the same way once collection and storage stay in Europe.
What’s left is aggregates: visits, pages viewed, traffic sources, breakdowns by country and device. That’s what you actually need to steer a product, and it’s also the kind of measurement European regulators treat as strictly necessary to running the service, provided it stays within certain limits. That’s the deliberate design choice behind Takt: no cookie, no individual identification, a managed service hosted in Europe, and a few kilobytes of script instead of a stack of tags. The full contrast with the Google Analytics approach is on our comparison page.
The payoff isn’t only legal. With no banner, nobody declines or ignores a modal: your numbers cover all of your traffic, not just the fraction of visitors who clicked “Accept”.
Installing the Takt bundle in Symfony
Before touching any code, create the site in Takt and note the domain you register there. That exact value is what goes in domain below: it’s the domain, not the host actually being served, that binds events to the right site.
The Symfony bridge then wraps the PHP core as a bundle: a Twig function that renders the browser runtime, and an autowired service for server-to-server events.
composer require vskstudio/takt-symfony Depending on your setup the bundle may not register itself: check that TaktBundle is listed in config/bundles.php and add it if it isn’t. Then create config/packages/takt.yaml:
takt:
domain: '%env(TAKT_DOMAIN)%'
mode: inline
outbound: true
files: true
tagged: true
not_found: true
file_extensions: ['pdf', 'zip'] The referenced variable has to exist, or Symfony throws an EnvNotFoundException as soon as a service depending on it gets instantiated — on the first page that renders the snippet, or earlier if cache warmup happens to touch that service:
TAKT_DOMAIN=example.com There’s deliberately no ingestion endpoint in this starter block: it points at the hosted Takt service, never at a server of your own, and the only reason to redefine it is if you serve measurement through a first-party proxy on your own domain. If your version of the bundle does require the endpoint key, the configuration error at boot will say so plainly — check the bundle reference, which is authoritative on the expected value. mode: inline is the default: it embeds the runtime directly in the tag, which saves a network request. outbound, files, tagged and not_found turn on the matching autocaptures — outbound links, downloads, elements tagged in the HTML, 404 pages. All of them are off by default, and file_extensions narrows which downloads get counted.
You’ll notice there’s no API key in that config: it’s only used by the server-to-server sending path, and we’ll add it further down. An app that only tracks pageviews doesn’t need one.
Rendering the snippet with the Twig function
The bundle registers a takt() Twig function. It belongs in the <head> of your base template, rendered once for the whole app:
{# templates/base.html.twig #}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>{% block title %}My app{% endblock %}</title>
{{ takt() }}
</head>
<body>
{% block body %}{% endblock %}
</body>
</html> That’s the whole pageview setup. Client-side navigation tracking is always on: there’s no flag to flip and no call to fire for navigations that go through pushState or the back button. The function also accepts one-off overrides — takt() with an options hash, to enable a capture on one part of the site — but hold that thought for the next section, because it has a surprising consequence.
Symfony + Turbo: the snippet placement trap
The runtime wraps history.pushState and listens for popstate. Any navigation going through either one therefore emits a pageview, with no configuration — the general principle behind measuring an app with client-side navigation. That’s precisely what Turbo Drive, the one shipped with symfony/ux-turbo, does: a Turbo visit is a pushState, and the browser’s back button fires a popstate. A Symfony app running Turbo is correctly measured out of the box — and you should specifically not add a turbo:load listener to “help”, because every navigation would then be counted twice.
One exception is worth knowing about: replaceState is not wrapped. A visit marked data-turbo-action="replace" therefore emits nothing. That’s usually what you want — those are the filter and sort changes that rewrite the URL without changing screen — but if you use it for real navigation, those pages will stay invisible in your stats.
The trap is somewhere else, and it’s about placement.
The runtime is a bare IIFE. It sets no “already loaded” global and has no re-initialisation guard: the only kill switch is the enabled: false option, which is a configuration choice, not protection against a double load. And the wrapper it installs looks like this:
var push = history.pushState; // captures the CURRENT value of pushState
history.pushState = function () {
var result = push.apply(this, arguments); // calls back into what it captured
pageview();
return result;
};
window.addEventListener("popstate", pageview); // one more subscriber per run On the first run, push is the native pushState. On the second, push is the wrapper the first run installed — not the native one. So the new wrapper doesn’t replace the old one: it encloses it. A single call to history.pushState then walks the whole stack down to native, and every layer fires its pageview on the way through.
The popstate listener follows the same slope. pageview is a fresh function on every run of the script, so the browser never recognises it as already subscribed and stacks listeners instead of deduplicating them. The back button therefore inflates exactly like links do.
Which leaves the question of why the script would run twice at all. Because Turbo doesn’t reload the page: it merges the <head> and replaces the <body>. Those two halves behave in opposite ways.
- The
<head>is merged: Turbo compares the incoming head elements against the ones already there and appends only what differs. A tag that’s identical from page to page is kept as-is, and is therefore not re-executed. - The
<body>is replaced: Turbo recreates the<script>elements of the new body so that they run. A script in the<body>is therefore re-executed on every visit.
Put the snippet in a content block, in a Turbo-rendered partial, or just before the closing </body>, and the wrappers stack up. The count becomes:
| Visit | Wrappers installed | Pageviews emitted |
|---|---|---|
| 1 (full page load) | 1 | 1 |
| 2 | 2 | 2 |
| 3 | 3 | 3 |
| N | N | N |
On each visit, Turbo’s pushState travels through every wrapper installed so far, then the re-executed script adds one more and fires its own load pageview on the way. A ten-page session doesn’t report ten pageviews but roughly fifty, and it grows quadratically. Depending on the Turbo version the count can be off by one on a given visit; the shape of the curve doesn’t change.
The symptom is recognisable: visitor counts stay plausible, but pageviews per session blow up, and they blow up harder the longer the session runs. A dashboard showing 40 pageviews for 3 visitors, with normal time on page, is describing this bug and not a burst of curiosity. Pageviews that come out exactly doubled, on the other hand, with no worsening as the session runs on, point at an entirely different mechanism — one navigation listener too many, which the Astro piece takes as its worked example.
The fix is one line: takt() belongs in the <head> of base.html.twig, never in a content block or a Turbo-rendered partial.
There’s a less obvious corollary. Because Turbo only keeps the tag when it is identical from page to page, an inline override that varies per template — enabling a capture on a single section, say — produces a different <head> on every navigation. Turbo treats it as a new element, appends it, and runs it: you land on exactly the same stacking, this time from the <head>. So render takt() with no arguments in the base template, and drive captures from the YAML configuration rather than from templates.
Tracking Symfony conversions server-side
Browser-side measurement is fine for pageviews. It’s much less reliable for money. An ad blocker, a tab closed during the return redirect from the payment provider, a checkout that finishes on a third-party domain: each one is a real order that never shows up in your stats. That’s why the revenue reported by an analytics tool never quite matches the back office.
Sending from PHP requires an API key. It belongs in the takt: block you already created — a config file cannot declare the same root key twice:
# config/packages/takt.yaml — the same takt: key, definitely not a second block
takt:
domain: '%env(TAKT_DOMAIN)%'
api_key: '%env(TAKT_API_KEY)%'
mode: inline
# …the rest of your configuration TAKT_API_KEY=your_ingest_key The bundle then exposes an autowired Takt service that posts events straight from PHP. The controller below is an example to adapt — what matters is the point in your checkout where the order is confirmed, not the exact route:
<?php
namespace App\Controller;
use App\Entity\Order;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Vskstudio\Takt\Revenue;
use Vskstudio\Takt\Takt;
class CheckoutController extends AbstractController
{
public function __construct(private readonly Takt $takt)
{
}
#[Route('/orders/{id}/confirmed', name: 'checkout_success')]
public function success(Order $order): Response
{
$this->takt->event(
'Purchase',
['plan' => $order->getPlan(), 'country' => $order->getCountry()],
new Revenue(amount: '29.00', currency: 'EUR'),
);
return $this->render('checkout/success.html.twig', ['order' => $order]);
}
} There’s nothing to wire: type-hint Vskstudio\Takt\Takt and autowiring does the rest. The service is RequestStack-aware, so it forwards the current request’s IP and User-Agent for attribution — you don’t have to pass them by hand the way you would with the bare PHP core. The Revenue amount is a decimal string, not a float: that’s deliberate, and it keeps binary rounding away from money. Sends are fire-and-forget: a 202 means success, and a transport failure is swallowed rather than propagated. Analytics should never take down a checkout.
One trade-off is worth making consciously. Emitting from the success controller, as above, attributes the conversion to the real visitor, since their request is in flight — but misses orders where the buyer never reaches the return page. Emitting from a payment webhook never misses anything; the request in flight, however, belongs to the payment provider and not to your customer, so the service forwards Stripe’s IP and User-Agent. Attribution is then wrong, but it isn’t lost: persist the visitor’s IP and User-Agent on the order at checkout, read them back in the webhook controller, and pass them to withVisitor() before sending. The piece on the Laravel integration walks through that plumbing, and it applies here unchanged since the service is the same PHP core. For most shops the return controller remains the simplest compromise; if you see heavy redirect abandonment, the webhook becomes the right choice, provided you carry attribution into it.
Checking that data comes through
Deploy, then load the live site with your devtools Network tab open. You should see a POST to /api/event on every page load.
On a Turbo project, make that the direct test for the trap above: click through four or five links in a row without leaving the Network tab, then go back once or twice, and count. There should be exactly one request per navigation, back button included. If the second navigation produces two and the third produces three, you’re stacking wrappers.
To tell which of the two cases you’re in, switch to the Elements panel and count the <script> tags containing takt after three navigations. If they pile up in the <head>, it’s the corollary: your snippet is rendered with options that vary from template to template. If there’s only one but it sits in the <body>, it’s the main trap — move it up into the head.
Locally you’ll see nothing at all: localhost and private IPs are excluded by default, as are visitors with Do Not Track or Global Privacy Control enabled. That’s intended behaviour, not a broken config. For a manual check from a public environment, the console is enough:
// from the browser console, on the deployed site
window.takt('test_event'); The event shows up in the dashboard in near real time, alongside the pageviews.
Wrapping up
One bundle, one domain to declare, one Twig function in the <head> of the base template: a Symfony app is measured with no cookie and no banner, Turbo included, and the autowired Takt service sends conversions from PHP, out of reach of ad blockers. The one genuinely sharp edge is placement: the runtime has no re-initialisation guard, and Turbo replaces the <body> while merging the <head>. A snippet placed anywhere but the head therefore stacks on every visit and inflates pageviews quadratically. One line in the right place and the problem never exists. The full option reference for the bundle is in the Symfony documentation.
Take the next step
Measure your audience without a consent banner.
See Takt in action, then install cookieless analytics on your site.
Read next
Adding analytics to a SvelteKit app without a cookie banner
Adding cookieless analytics to SvelteKit: an SSR-safe component in the layout, events via useTakt, and why you see nothing in development.
August 1, 2026· 9 min read
GuidesAdding analytics to a Laravel app without a cookie banner
Cookieless analytics in Laravel 10/11/12: the Blade directive, server-side conversion tracking, and the attribution trap in queued jobs.
August 1, 2026· 9 min read
GuidesAdding analytics to an Astro site without a cookie banner
Cookieless analytics on Astro: integration or component, View Transitions, and why an astro:page-load listener doubles your pageviews.
August 1, 2026· 9 min read
Stay in time
An occasional email on privacy, compliance and Takt news. No tracking, no spam.
Your email is only used to send the newsletter, never sold.