Aller au contenu
← Retour au blog
Guides 1 août 2026· 9 min de lecture

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.

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 :

VisiteEnrobages installésPages vues émises
1 (chargement complet)11
222
333
NNN

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

Partager