Ajouter des analytics sans cookie à une application Symfony
Analytics sans cookie dans une application Symfony 6.4/7.x : fonction Twig, conversions côté serveur, et le piège Turbo qui multiplie les pages vues.
Sommaire
Vous cherchez à mesurer l’audience d’une application Symfony, et la première page de résultats vous renvoie invariablement vers des tutoriels de bandeau de consentement : brancher un CMP, câbler le mode consentement de Google Analytics, gérer les refus, stocker le choix. Dans tous ces guides, le bandeau est un point de départ, jamais un résultat. C’est pourtant exactement ce qu’il est. Si vous devez afficher un bandeau, c’est parce que l’outil que vous avez retenu dépose un cookie et envoie des données hors de l’Union européenne. Changez d’outil, le bandeau part avec lui — et vos statistiques cessent au passage de ne décrire qu’une partie de votre trafic.
Ce guide montre l’intégration complète côté Symfony 6.4 / 7.x : le bundle, la fonction Twig, le suivi des conversions depuis PHP, et un piège de placement qui, dès que Turbo entre dans l’équation, multiplie silencieusement vos pages vues.
Pourquoi une mesure sans cookie n’a pas besoin de bandeau
Le consentement n’est pas exigé par « la mesure d’audience » en tant que telle. Il est exigé par l’écriture ou la lecture d’informations dans le terminal du visiteur — un cookie, un identifiant en stockage local, une empreinte de navigateur. Une mesure qui n’écrit rien dans le navigateur et ne reconstitue aucun identifiant stable sort de ce périmètre. Aucun profil publicitaire n’est constitué, aucun visiteur n’est reconnu d’une visite à l’autre. Le deuxième point de friction, le transfert de données vers des serveurs hors UE, disparaît de la même façon dès lors que la collecte et le stockage restent en Europe.
Ce qu’il reste alors, ce sont des agrégats : nombre de visites, pages consultées, sources de trafic, répartition par pays et par appareil. C’est ce dont vous avez besoin pour piloter un produit, et c’est aussi la forme de mesure que la CNIL range, sous conditions, parmi les traceurs strictement nécessaires au fonctionnement du service. Chez Takt, c’est le parti pris de conception : pas de cookie, pas d’identification individuelle, un service géré et hébergé en Europe, et un script de quelques kilo-octets au lieu d’une pile de balises. La différence avec l’approche Google Analytics est détaillée sur notre page comparative.
Le gain concret n’est pas seulement juridique. Sans bandeau, plus personne ne refuse ni n’ignore la fenêtre : vos chiffres portent sur l’ensemble du trafic, et non sur la seule fraction de visiteurs ayant cliqué sur « Accepter ».
Installer le bundle Takt dans Symfony
Avant de toucher au code, créez le site dans Takt et notez le domaine que vous y déclarez. C’est cette valeur exacte qui devra figurer dans domain ci-dessous : c’est elle, et non l’hôte réellement servi, qui rattache les événements au bon site.
Le bridge Symfony expose ensuite le cœur PHP sous forme de bundle : une fonction Twig pour rendre le runtime navigateur, et un service autowiré pour les événements serveur-à-serveur.
composer require vskstudio/takt-symfony Selon votre installation, le bundle n’est pas forcément déclaré tout seul : vérifiez que TaktBundle figure bien dans config/bundles.php, et ajoutez-le sinon. Créez ensuite config/packages/takt.yaml :
takt:
domain: '%env(TAKT_DOMAIN)%'
mode: inline
outbound: true
files: true
tagged: true
not_found: true
file_extensions: ['pdf', 'zip'] La variable référencée doit exister, sans quoi Symfony lève une EnvNotFoundException dès qu’un service qui en dépend est instancié — à la première page qui rend le snippet, voire plus tôt si le chauffage du cache touche ce service :
TAKT_DOMAIN=exemple.fr Vous ne trouverez pas d’adresse d’ingestion dans ce bloc de démarrage : elle vise le service Takt hébergé, jamais un serveur à vous, et vous n’avez de raison de la redéfinir que si vous servez la mesure derrière un proxy first-party sur votre propre domaine. Si votre version du bundle réclame malgré tout la clé endpoint, l’erreur de configuration au démarrage sera explicite : reportez-vous alors à la référence du bundle, qui fait foi sur la valeur attendue. mode: inline est le défaut : il embarque le runtime directement dans la balise, ce qui évite une requête réseau supplémentaire. outbound, files, tagged et not_found activent les captures automatiques correspondantes — liens sortants, téléchargements, éléments marqués dans le HTML, pages 404. Toutes sont désactivées par défaut, et file_extensions restreint la liste des téléchargements comptés.
Vous remarquerez qu’il n’y a pas de clé d’API dans cette configuration : elle ne sert qu’aux envois serveur-à-serveur, et on l’ajoutera plus bas. Une application qui ne suit que des pages vues n’en a pas besoin.
Rendre le snippet avec la fonction Twig
Le bundle ajoute une fonction Twig takt(). Elle se rend dans le <head> du template de base, une seule fois pour toute l’application :
{# templates/base.html.twig #}
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="utf-8">
<title>{% block title %}Mon application{% endblock %}</title>
{{ takt() }}
</head>
<body>
{% block body %}{% endblock %}
</body>
</html> C’est tout pour la mesure des pages vues. Le suivi de la navigation côté client est toujours actif : il n’y a pas d’option à cocher, pas d’appel à déclencher pour les navigations qui passent par pushState ou par le bouton « retour ». La fonction accepte par ailleurs des surcharges ponctuelles, par exemple takt() avec un tableau d’options pour activer une capture sur une section précise du site — mais gardez cette possibilité en tête pour la section suivante, elle a une conséquence inattendue.
Symfony + Turbo : le piège du placement du snippet
Le runtime enveloppe history.pushState et écoute popstate. Toute navigation qui passe par l’un ou l’autre émet donc une page vue, sans configuration — c’est le principe général de la mesure d’une application à navigation client. C’est exactement ce que fait Turbo Drive, livré avec symfony/ux-turbo : une visite Turbo est un pushState, et le bouton « retour » du navigateur déclenche un popstate. Un projet Symfony sous Turbo est donc correctement mesuré dès l’installation, et il ne faut surtout pas ajouter d’écouteur turbo:load pour « aider » — chaque navigation serait comptée deux fois.
Une exception mérite d’être connue : replaceState n’est pas enveloppé. Une visite marquée data-turbo-action="replace" n’émet donc aucune page vue. C’est le plus souvent ce que vous voulez — ce sont les changements de filtre ou de tri qui réécrivent l’URL sans changer d’écran — mais si vous vous en servez pour de vraies navigations, elles resteront invisibles dans vos statistiques.
Le piège est ailleurs, et il est dans le placement.
Le runtime est une IIFE nue. Il ne pose aucun drapeau global du type « je suis déjà chargé », et n’a pas de garde de ré-initialisation : le seul coupe-circuit est l’option enabled: false, qui est un choix de configuration, pas une protection contre un double chargement. Et l’enrobage qu’il installe se comporte ainsi :
var push = history.pushState; // capture la valeur COURANTE de pushState
history.pushState = function () {
var result = push.apply(this, arguments); // rappelle ce qui a été capturé
pageview();
return result;
};
window.addEventListener("popstate", pageview); // un abonné de plus par exécution À la première exécution, push est le pushState natif. À la seconde, push est l’enrobage posé par la première — pas le natif. Le nouvel enrobage ne remplace donc pas l’ancien : il l’englobe. Un seul appel à history.pushState redescend alors toute la pile jusqu’au natif, et chaque étage émet sa page vue au passage.
L’écouteur popstate suit la même pente. La fonction pageview est recréée à chaque exécution du script : le navigateur ne la reconnaît jamais comme déjà abonnée et empile les écouteurs au lieu de les dédoublonner. Le bouton « retour » gonfle donc exactement comme les liens.
Reste à savoir pourquoi le script s’exécuterait deux fois. Parce que Turbo ne recharge pas la page : il fusionne le <head> et remplace le <body>. Ces deux moitiés se comportent de façon opposée.
- Le
<head>est fusionné : Turbo compare les éléments du nouvel en-tête à ceux déjà présents et n’ajoute que ceux qui en diffèrent. Une balise identique d’une page à l’autre est conservée telle quelle, et n’est donc pas ré-exécutée. - Le
<body>est remplacé : Turbo recrée les balises<script>du nouveau corps de page pour qu’elles s’exécutent. Un script dans le<body>est donc ré-exécuté à chaque visite.
Mettez le snippet dans un bloc de contenu, dans un partiel rendu par Turbo ou juste avant la fermeture du <body>, et les enrobages s’empilent. Le décompte devient :
| Visite | Enrobages installés | Pages vues émises |
|---|---|---|
| 1 (chargement complet) | 1 | 1 |
| 2 | 2 | 2 |
| 3 | 3 | 3 |
| N | N | N |
À chaque visite, le pushState de Turbo traverse tous les enrobages installés jusque-là, puis le script ré-exécuté en ajoute un de plus et émet au passage sa propre page vue de chargement. Le total d’une session de dix pages n’est pas dix pages vues mais une cinquantaine, et il croît de façon quadratique. Selon la version de Turbo, le décalage peut être d’une unité sur une visite donnée ; la forme de la courbe, elle, ne change pas.
Le symptôme est reconnaissable : le nombre de visiteurs reste plausible, mais les pages vues par session explosent, et elles explosent d’autant plus que la session est longue. Un tableau de bord qui annonce 40 pages vues pour 3 visiteurs, avec un temps passé normal, décrit ce bug et pas un pic de curiosité. Des pages vues exactement doublées, en revanche, sans aggravation quand la session s’allonge, désignent un tout autre mécanisme : un écouteur de navigation en trop, dont l’article Astro fait son cas d’école.
La correction tient en une ligne : takt() appartient au <head> de base.html.twig, jamais à un bloc de contenu ni à un partiel rendu par Turbo.
Il reste un corollaire moins évident. Puisque Turbo ne conserve la balise que si elle est identique d’une page à l’autre, une surcharge inline qui varie selon le gabarit — activer une capture sur une seule section du site, par exemple — produit un <head> différent à chaque navigation. Turbo la traite alors comme un élément nouveau, l’ajoute, et l’exécute : on retombe exactement sur le même empilement, cette fois depuis le <head>. Rendez donc takt() sans argument dans le gabarit de base, et pilotez les captures par la configuration YAML plutôt que par le template.
Suivre les conversions Symfony côté serveur
La mesure côté navigateur suffit pour les pages vues. Elle est beaucoup moins fiable pour l’argent. Un bloqueur de publicité, un onglet fermé pendant la redirection de retour du prestataire de paiement, un tunnel qui se termine sur un domaine tiers : autant de commandes réelles qui n’apparaîtront jamais dans vos statistiques. C’est la raison pour laquelle le chiffre d’affaires affiché par un outil d’analytics ne correspond jamais tout à fait à celui du back-office.
Les envois depuis PHP demandent une clé d’API. Elle se range dans le bloc takt: que vous avez déjà créé — un fichier de configuration ne peut pas déclarer deux fois la même clé de racine :
# config/packages/takt.yaml — la même clé takt:, surtout pas un second bloc
takt:
domain: '%env(TAKT_DOMAIN)%'
api_key: '%env(TAKT_API_KEY)%'
mode: inline
# …le reste de votre configuration TAKT_API_KEY=votre_cle_ingest Le bundle expose alors un service Takt autowiré, qui poste les événements directement depuis PHP. Le contrôleur ci-dessous est un exemple à adapter — c’est l’endroit de votre tunnel où la commande est confirmée qui compte, pas la route exacte :
<?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('/commande/{id}/confirmee', 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]);
}
} Il n’y a rien à câbler : un simple type-hint sur Vskstudio\Takt\Takt suffit, l’autowiring fait le reste. Le service est RequestStack-aware, donc il transmet l’IP et l’User-Agent de la requête en cours pour l’attribution — vous n’avez pas à les passer à la main comme avec le cœur PHP nu. Le montant du Revenue est une chaîne décimale, pas un flottant : c’est délibéré, cela évite les arrondis binaires sur des sommes d’argent. Les envois sont en fire-and-forget : un 202 vaut succès, et une panne de transport est avalée plutôt que propagée. L’analytics ne doit jamais faire tomber un tunnel de paiement.
Un arbitrage mérite d’être fait en conscience. Émettre depuis le contrôleur de succès, comme ci-dessus, attribue la conversion au vrai visiteur, puisque sa requête est en cours — mais rate les commandes dont l’acheteur n’atteint jamais la page de retour. Émettre depuis un webhook de paiement ne rate jamais rien ; en revanche la requête en cours est celle du prestataire de paiement et non celle de votre client, donc le service forwarde l’IP et l’User-Agent de Stripe. L’attribution est alors fausse, mais elle n’est pas perdue : persistez l’IP et l’User-Agent du visiteur sur la commande au passage en caisse, relisez-les dans le contrôleur de webhook, et passez-les à withVisitor() avant l’envoi. L’article sur l’intégration Laravel détaille ce montage, qui vaut à l’identique ici puisque le service est le même cœur PHP. Pour la plupart des boutiques, le contrôleur de retour reste le compromis le plus simple ; si vos abandons de redirection sont massifs, le webhook devient le bon choix, à condition d’y transporter l’attribution.
Vérifier que la mesure remonte
Déployez, puis ouvrez le site en ligne et l’onglet Réseau des outils de développement. Vous devez voir une requête POST vers /api/event à chaque chargement de page.
Sur un projet Turbo, faites-en le test direct du piège précédent : cliquez sur quatre ou cinq liens d’affilée sans quitter l’onglet Réseau, puis revenez en arrière une fois ou deux, et comptez. Il doit y avoir exactement une requête par navigation, retours compris. Si la deuxième navigation en produit deux et la troisième trois, vous êtes en train d’empiler des enrobages.
Pour savoir lequel des deux cas vous concerne, passez à l’onglet Éléments et comptez les balises <script> contenant takt après trois navigations. Si elles s’accumulent dans le <head>, c’est le corollaire : votre snippet est rendu avec des options qui varient d’un gabarit à l’autre. S’il n’y en a qu’une mais qu’elle se trouve dans le <body>, c’est le piège principal : remontez-la dans l’en-tête.
En local, vous ne verrez rien remonter : localhost et les IP privées sont exclus par défaut, comme les visiteurs qui ont activé Do Not Track ou Global Privacy Control. C’est le comportement voulu, pas une panne de configuration. Pour un test manuel depuis un environnement public, la console suffit :
// depuis la console du navigateur, sur le site déployé
window.takt('test_event'); L’événement apparaît en temps quasi réel dans le tableau de bord, aux côtés des pages vues.
En résumé
Un bundle, un domaine à déclarer, une fonction Twig dans le <head> du gabarit de base : une application Symfony est mesurée sans cookie ni bandeau, Turbo compris, et le service Takt autowiré envoie les conversions depuis PHP, hors de portée des bloqueurs. Le seul point réellement piégeux est le placement : le runtime n’a pas de garde de ré-initialisation, et Turbo remplace le <body> alors qu’il fusionne le <head>. Un snippet posé ailleurs que dans l’en-tête s’empile donc à chaque visite et gonfle les pages vues de façon quadratique. Une ligne au bon endroit, et le problème n’existe pas. La référence complète des options du bundle est dans la documentation Symfony.
Passer à l'action
Mesurez votre audience sans bannière.
Voyez Takt en action, puis installez une analytics sans cookies sur votre site.
À lire ensuite
Ajouter des analytics à une app SvelteKit sans bandeau cookies
Analytics sans cookie dans SvelteKit : composant sûr au SSR dans le layout, events via useTakt, et pourquoi vous ne voyez rien en développement.
1 août 2026· 9 min de lecture
GuidesAjouter des analytics à une app Laravel sans bandeau cookies
Analytics sans cookie dans Laravel 10/11/12 : directive Blade, conversions mesurées côté serveur, et le piège d'attribution des jobs en file.
1 août 2026· 9 min de lecture
GuidesAjouter des analytics à un site Astro sans bandeau cookies
Analytics sans cookie sur Astro : intégration ou composant, View Transitions, et pourquoi un listener astro:page-load double vos pages vues.
1 août 2026· 9 min de lecture
Restez au rythme
Un e-mail occasionnel sur la confidentialité, la conformité et les nouveautés Takt. Pas de pistage, pas de spam.
Votre e-mail sert uniquement à l'envoi de la newsletter, jamais revendu.