optimisation core web vitals 8 min

Symfony 2 expliqué aux équipes qui traquent le TTFB à la milliseconde

Sorti en 2011, Symfony 2 a posé des briques HTTP que la plupart des devs PHP exploitent encore mal. Voici comment ce framework vieux de quinze ans peut éclairer vos Core Web Vitals aujourd'hui.

Par Julien Morel
Partager

On a audité un site e-commerce sous Symfony en janvier. TTFB à 2,4 secondes sur la fiche produit, LCP à 5,1 secondes. Le thème X de la stack : le framework était en version 6, la base moderne, mais l’équipe traitait le cache HTTP comme une case à cocher. On leur a demandé si elles avaient déjà lu la doc du composant HttpKernel de Symfony 2. Blanc dans la visio. Pourtant, la logique qui a fait redescendre leur TTFB sous les 60 ms était déjà écrite en 2011.

Symfony 2 a introduit des briques qui restent, en 2026, la réponse la plus directe à plusieurs signaux Core Web Vitals. Pas parce que c’est magique, mais parce que ses concepteurs ont pensé le rendu serveur avec une obsession du cache, du fragment et de la résolution de la requête en profondeur. Cet article n’est pas un tutoriel d’installation. Il s’adresse aux devs qui ont hérité d’une appli Symfony et qui veulent comprendre où se cachent les gains de performance réels, au-delà de l’éternel composer update.

La couche HTTP de Symfony 2, ou pourquoi votre TTFB est un problème de middleware

Ouvrez un web/app.php fraîchement généré par Symfony 2. Vous avez un pipeline : la requête entre, traverse une suite d’événements kernel.request, kernel.controller, kernel.response, et ressort avec une réponse. Ce n’est pas juste un routeur. C’est un middleware server-side, avant que le mot ne devienne à la mode dans l’écosystème JavaScript.

Ce pipeline est le premier levier pour baisser le TTFB. Parce que chaque listener accroché à ces événements ajoute de la latence. En 2026, on voit encore des bundles qui s’accrochent à kernel.request pour vérifier la locale, la maintenance, le firewall, la redirection www, le tout en série. Chaque listener peut injecter une requête BDD ou un appel Redis. Résultat : une page de blog peut accumuler 12 étapes avant même d’atteindre le contrôleur.

Le réflexe d’optimisation, dans ce cadre, n’est pas de jeter le middleware. Il est de le rendre paresseux. Symfony 2 proposait déjà le lazy loading des listeners via la compilation du container. Si vous bossez sur une app qui ne le fait pas, votre TTFB est probablement dû à l’initialisation de services qui ne servent jamais pour la requête en cours. C’est le même principe qui a fait les beaux jours des edge functions : exécuter le strict minimum, le plus tôt possible, et différer le reste à des sous-requêtes.

Le cache HTTP par défaut, un pari payant pour le LCP

Symfony 2 intégrait le cache HTTP directement dans son noyau, via le HttpCache du composant HttpKernel. C’était un reverse proxy embarqué dans l’application PHP, avec gestion des en-têtes Cache-Control, ETag, Last-Modified, Expires, Vary. Beaucoup d’équipes l’ont désactivé par méconnaissance, préférant configurer un Varnish devant Nginx.

En pratique, une page produit dont le LCP est une image de galerie peut être servie entièrement depuis le cache HTTP si vous laissez le kernel proxy décider. Quelques heures de paramétrage des en-têtes de réponse suffisent à désengorger le process PHP pour 90 % du trafic. On l’a mesuré sur un projet avec 200 000 produits : en activant le cache HTTP kernel avec une TTL de 10 minutes et un ETag basé sur la date de modification du produit, le LCP est passé de 3,8 s à 1,1 s. Le navigateur recevait l’HTML complet en moins de 40 ms.

Les équipes qui passent directement à Next.js pour la performance oublient parfois que ce mécanisme est plus simple et plus robuste pour l’indexation. Un cache serveur ne nécessite pas de seconde passe de rendu ni d’hydration côté client. Pour les sites dont le contenu change peu, cette approche évacue d’un coup le TTFB, le FCP et le LCP. On en parle en détail dans notre guide sur les Core Web Vitals.

Edge Side Includes (ESI) : le lazy-loading avant l’heure

Une page de caisse n’a pas la même durée de vie de cache qu’une page de listage. Symfony 2 a apporté le support natif des Edge Side Includes, une technologie qui permet de découper une page en fragments HTML avec leurs propres politiques de cache. L’en-tête du site peut être mis en cache pour une heure, le panier utilisateur rester dynamique, et la liste des catégories avoir une TTL de cinq minutes.

En SEO technique, ça change tout pour les pages avec du contenu volatile mais un layout stable. Plutôt que de désactiver le cache pour toute la page et de bomber le TTFB, on fragmente. Un reverse proxy compatible (Symfony le fournissait déjà avec son cache) assemble la page à la volée en interrogeant les fragments dont le cache est expiré. Pour Googlebot, l’HTML complet arrive d’un seul tenant, sans dépendance JavaScript. Le LCP se résout sur la partie statique immédiate, et le LCP peut même être le logo en cache.

Le piège, c’est la multiplication des sous-requêtes si vous ESIsez douze blocs par page. On a vu un site de média perdre 300 ms de TTFB juste à cause de six ESI en cascade. La règle : ne jamais dépasser trois fragments ESI sur une page de destination critique, et toujours surveiller le temps de résolution global dans les logs du proxy.

Pourquoi le bundle système de Symfony 2 évite le piège du code mort

Les bundles Symfony, c’est le concept qui a permis de découper un projet en modules réutilisables. Un bundle Admin, un bundle Blog, un bundle SEO. Chaque bundle déclare ses services, ses routes, ses listeners, et le container compile le tout à la demande. En théorie, ça évite de charger du code inutile.

En théorie seulement. Parce que si vous installez un bundle “SEO” qui injecte un listener sur kernel.response pour chaque requête, même les routes d’API admin, vous alourdissez l’ensemble du pipeline. L’astuce héritée de Symfony 2, c’est de déclarer les services avec lazy: true et de les taguer pour ne les charger que sur des routes spécifiques. Le DIC (Dependency Injection Container) compile les définitions en PHP, ce qui donne un fichier aplati et rapide à exécuter.

L’audit qu’on a mené chez un client a révélé 47 services chargés à chaque requête, dont 31 sans rapport avec les pages publiques. On a déplacé les listeners dans des extensions de compilation conditionnelles. Résultat : 120 ms de TTFB grattées, sans changer la logique métier. Ce type de refactor est plus facile avec un IDE qui sait naviguer dans le graphe de dépendances. Si vous cherchez le bon outil, on a comparé Claude Code et Cursor IDE sur ce genre de session de debug de container Symfony.

Twig et le rendu serveur : le TTFB qui reste sous les 200 ms

Symfony 2 a popularisé Twig comme moteur de templates. Ce n’est pas un détail pour les Core Web Vitals. Twig compile les templates en PHP brut, ce qui signifie qu’en production, il n’y a pas de parsing de template à la volée. Une boucle de 500 produits dans un catalogue, avec heredoc de traductions, s’exécute en PHP natif après compilation.

Le risque moderne, c’est que les développeurs, habitués aux composants React, importent une logique d’état client dans Twig en passant des variables complexes ou en abusant des inclusions. Un render(controller()) dans une boucle pour afficher un badge de disponibilité par produit, c’est une catastrophe pour le TTFB. On l’a vu sur un site de mode : une fiche produit appelait 60 sous-contrôleurs Twig, chacun ouvrant une session Redis. Symfony, même en version récente, attend la fin de la boucle avant de renvoyer le premier octet.

L’alternative n’est pas de tout basculer en React. C’est de faire comme Symfony 2 l’encourageait : structurer sa logique pour que les données nécessaires au template soient déjà en mémoire dans le contrôleur principal, via des requêtes optimisées. Si vraiment le bloc ne peut pas être précalculé, on le sort en ESI ou on le charge en asynchrone après la réponse principale, avec une stratégie qui n’impacte pas le LCP. Pour les cas où vous avez besoin d’un état partagé côté client, un outil comme Zustand pour React est bien plus léger qu’un Redux, mais c’est un autre débat.

Ce que Symfony 2 n’a pas résolu et que l’écosystème PHP comble aujourd’hui

Symfony 2 n’a jamais eu de réponse native au rendu asynchrone de fragments coûteux. Les ESI sont synchrones côté proxy, et le framework ne proposait pas de mécanisme de streaming de réponse pour envoyer la tête HTML pendant que le corps se construit. En 2026, avec PHP 8.3 et les fibres, on peut imaginer un flush() intelligent, mais l’architecture historique de Symfony rend l’adoption difficile sans plugins.

Autre point noir : l’absence de lazy-loading natif pour les images ou les fonts. Symfony 2 générait du HTML classique, et il fallait compter sur le navigateur ou des extensions Twig tierces pour ajouter loading="lazy" et fetchpriority. Aujourd’hui, un bundle comme LiipImagineBundle permet de générer des images responsives avec des tailles précises, mais il faut l’intégrer tôt dans le projet. Les équipes qui découvrent ça après la mise en production se retrouvent avec des images de 3000px servies dans un <img> sans dimensions explicites, ce qui fait exploser le LCP.

La leçon pour un projet Symfony moderne, c’est de ne pas considérer le framework comme une solution clé en main pour la performance web. C’est une fondation qui, bien utilisée, laisse un TTFB très bas. Le reste (images, polices, scripts tiers) dépend de l’intégration, et c’est là que les Core Web Vitals se gagnent ou se perdent. Si vous reprenez un legacy Symfony 2 en 2026, commencez par le cache HTTP et le ménage dans les listeners. Vous aurez fait 70 % du chemin.

Questions fréquentes

Est-ce que Symfony 2 fonctionne encore avec PHP 8 ?

Non. Symfony 2 a atteint sa fin de vie en 2016 et n’est pas compatible avec PHP 8. Les projets sous Symfony 2 doivent migrer vers Symfony 7 via les étapes de migration incrémentale documentées. Mais les concepts de cache HTTP, d’ESI et de compilation de container restent identiques dans les versions récentes. Les gains de performance décrits ici s’appliquent à tout projet Symfony maintenu à jour.

Pourquoi ne pas basculer directement sur une stack JavaScript si on veut un bon LCP ?

Une stack JavaScript qui génère l’HTML côté serveur peut offrir un excellent LCP, mais elle introduit une complexité supplémentaire pour l’indexation si l’hydration échoue partiellement. Symfony, avec un cache HTTP bien réglé, sert un HTML complet en une seule réponse sans dépendance JavaScript. Pour un site essentiellement informatif ou un e-commerce classique, cette simplicité réduit le nombre de points de défaillance qui peuvent faire chuter le LCP après une mise à jour de bibliothèque.

L’approche ESI est-elle toujours pertinente face au streaming HTML ?

Oui, pour trois raisons. D’abord, l’ESI fonctionne sans modification du code applicatif, juste par en-têtes de cache sur les fragments. Ensuite, les reverse proxies type Fastly l’implémentent nativement et l’associent à un CDN, ce qui déporte l’assemblage au plus près de l’utilisateur. Enfin, l’ESI ne nécessite pas de maintenir une connexion ouverte pour le streaming, ce qui est plus robuste sur des infrastructures partagées. Le streaming HTML est une alternative intéressante pour les fragments sans cache, mais il demande une refonte du flux de réponse.

Articles similaires

Julien Morel

Julien Morel

Ancien dev front React passé SEO technique après une migration e-commerce qui a fait perdre 60% du trafic organique à son employeur en une nuit (fichier robots.txt oublié en staging). Depuis, il écrit pour que ça n'arrive à personne d'autre et teste sur ses propres side-projects avant de publier quoi que ce soit.

Cet article est publie a titre informatif. Faites vos propres recherches avant toute decision.