Symfony 8.2 permet de définir des règles de cache distinctes pour le CDN et pour le navigateur grâce à cacheControl('CDN'), qui génère l'en-tête CDN-Cache-Control défini par la RFC 9213. Le HttpCache de Symfony peut aussi émettre l'en-tête standard Cache-Status (RFC 9211). Présentées le 5 octobre 2026 sur le blog officiel, contributions de Nicolas Grekas (PR #65282) et Pascal Cescon (PR #65573), ces nouveautés arrivent avec Symfony 8.2, attendu en novembre 2026.
Tu veux que ton CDN garde une page une heure, mais tu ne veux pas que le navigateur de tes visiteurs en fasse autant. Tu peux purger le CDN quand le contenu change ; tu ne peux pas purger les copies disséminées chez tes utilisateurs. Avec un seul Cache-Control, il faut arbitrer : soit tu sacrifies le taux de hit du CDN, soit tu acceptes des pages périmées côté client.
Pourquoi un CDN et un navigateur ne devraient pas avoir la même règle
Une réponse HTTP traverse souvent plusieurs caches : le navigateur, cache privé ; un CDN, cache partagé que tu peux purger ; parfois un reverse proxy comme Varnish ou le HttpCache de Symfony. Le raisonnement tient en une phrase : le CDN se purge, le navigateur non. Tu peux laisser le premier cacher longtemps, parce que tu gardes la main sur l'invalidation, et tu dois garder le second court. Jusqu'ici, on bricolait avec max-age=0, s-maxage=3600, ce qui fonctionne tant qu'il n'y a qu'un seul cache partagé. Avec un CDN et un proxy, les deux lisent s-maxage et tu ne peux plus les distinguer. Le cache HTTP évite l'appel à l'application ; pour accélérer ceux qui restent, le worker mode de FrankenPHP est un levier complémentaire.
Qu’est-ce que CDN-Cache-Control ?
CDN-Cache-Control est un en-tête HTTP ciblé, défini par la RFC 9213, qu'un CDN compatible utilise à la place de Cache-Control. Les navigateurs continuent de lire Cache-Control et ignorent l'autre. La RFC précise qu'un cache qui trouve son champ ciblé l'utilise et ignore Cache-Control et Expires pour cette réponse, et qu'il doit au minimum implémenter max-age, must-revalidate, no-store, no-cache et private. Exemple tiré de la RFC :
Cache-Control: max-age=60, s-maxage=120
CDN-Cache-Control: max-age=600Ici, les CDN gardent la réponse 600 secondes, les autres caches partagés 120, et les navigateurs 60. Chaque cache obtient ses propres règles sur la même réponse.
Comment utiliser cacheControl('CDN') dans Symfony 8.2
La nouvelle méthode Response::cacheControl() prend le nom de la cible, CDN, pas le nom complet de l'en-tête. Exemple du billet :
// browsers consider the response stale immediately...
$response->setMaxAge(0)->setPrivate();
// ...but the CDN can cache it for one hour
$response->cacheControl('CDN')
->setMaxAge(3600)
->setStaleWhileRevalidate(60);Ce code génère les en-têtes suivants :
Cache-Control: max-age=0, private
CDN-Cache-Control: max-age=3600, stale-while-revalidate=60L'objet retourné propose setPublic(), setPrivate(), setStaleIfError(), et set() pour les autres directives. Selon le code de la branche 8.2, la cible est validée comme un token HTTP : un nom invalide lève une exception. Aucune API existante ne change, et rien n'est déprécié.
Faut-il utiliser setSharedMaxAge() avec cacheControl('CDN') ?
Non, et la méthode n'existe d'ailleurs pas sur l'objet retourné. La cible identifie déjà le cache concerné, donc setMaxAge() suffit. La PR #65282 détaille le raisonnement : s-maxage n'existe dans Cache-Control que pour distinguer caches partagés et privés, et la RFC classe son support dans la partie facultative. Comme un cache ignore Cache-Control dès qu'il lit son champ ciblé, émettre seulement s-maxage dans CDN-Cache-Control pourrait le laisser sans aucune information de fraîcheur. Si ton CDN documente explicitement s-maxage dans le champ ciblé, set('s-maxage', '3600') reste possible. Le piège à la migration :
// À éviter : setSharedMaxAge() n'existe pas sur l'objet retourné
$response->cacheControl('CDN')->setSharedMaxAge(3600); // Error: méthode inexistante
// Correct : max-age dans le champ ciblé
$response->cacheControl('CDN')->setMaxAge(3600);Votre dette technique s'accumule ?
Audit complet en 10 jours. Recommandations priorisées et actionnables.
Qu’est-ce que l’en-tête Cache-Status ?
Cache-Status est un en-tête HTTP, défini par la RFC 9211, par lequel chaque cache indique s'il a servi la réponse ou transmis la requête à l'application. Jusqu'ici, chaque cache répondait à sa façon, avec des en-têtes maison ; ce standard donne un vocabulaire commun. C'est une liste : le premier membre est le cache le plus proche de l'origine, le dernier celui le plus proche de l'utilisateur.
Comment activer cache_status dans HttpCache
# config/packages/framework.yaml
framework:
http_cache:
enabled: true
cache_status: 'Symfony'Les réponses portent alors un en-tête de ce type, d’après le billet :
Cache-Status: Symfony; hit; ttl=58
Cache-Status: Symfony; fwd=miss; stored
Cache-Status: Symfony; fwd=stale; fwd-status=304; storedSymfony; hit; ttl=58: le cache a répondu seul, avec 58 secondes de fraîcheur restantes.Symfony; fwd=miss; stored: la requête est partie vers l’application, et la réponse a été stockée.Symfony; fwd=stale; fwd-status=304; stored: la copie était périmée, l’application a répondu 304, et Symfony a réutilisé sa copie.
Un ttl négatif signifie que le cache a servi une réponse périmée. Si un autre cache a déjà posé un Cache-Status en amont, Symfony ajoute sa propre entrée au lieu de remplacer : avec l'en-tête activé aussi sur le CDN, tu suis la réponse à travers toute la chaîne.
Peut-on activer Cache-Status en production ?
Oui, contrairement à l'en-tête de debug X-Symfony-Cache. Il est pourtant désactivé par défaut (cache_status: null), parce qu'il expose des détails de ton cache. La RFC 9211 précise le risque : savoir si un cache a stocké une réponse peut aider une attaque par timing sur des données sensibles, et elle liste comme parades l'omission de l'en-tête ou son envoi aux seuls clients autorisés. Pour une API ou un back-office, retire-le à la sortie de ton edge, ou réserve-le à un en-tête interne.
Les limites à connaître
- Support CDN : le billet ne nomme aucun fournisseur. La description de la PR cite Fastly, Cloudflare et Akamai ; je ne l’ai pas vérifié dans leur documentation. Sans support, tu ajoutes un en-tête inerte.
- HttpCache de Symfony : il ne lit pas
CDN-Cache-Control, volontairement. S’il est devant ton application, il continue d’utiliserCache-Control. - Pages personnalisées : la RFC 9213 avertit que plusieurs politiques sur une même réponse peuvent mener à réutiliser des données sensibles. Une page personnalisée dans
CDN-Cache-Controlexige queVaryet les cookies soient bien gérés côté CDN, qui n’appliquera plus tonprivate. - Autres cibles : seule la cible
CDNest documentée dans le billet.
Ce cache HTTP s'ajoute aux autres étages de cache de ton application, comme le second level cache de Doctrine côté ORM, et aux gains de performance de Symfony 8.2 sur le Serializer et le warmup.
Que retenir et par où commencer
- Vérifie dans la documentation de ton CDN qu’il lit
CDN-Cache-Control, puis teste avec uncurl -Ien préproduction. - Sur les pages publiques que tu sais purger, garde
Cache-Controlcourt et privé pour le navigateur, et mets la durée longue danscacheControl('CDN')->setMaxAge(...). - Utilise
setMaxAge(), pass-maxage, dans le champ ciblé. - Active
cache_statusen préproduction, puis décide explicitement de son exposition en production. - Vérifie que ta stratégie de purge couvre bien les entrées CDN dont tu allonges la durée de vie.
Ces en-têtes ne corrigent rien tout seuls : ils rendent visible un comportement du cache que beaucoup d'applications n'ont jamais examiné. Un private oublié sur une réponse personnalisée, un TTL CDN identique au TTL navigateur, une réponse périmée servie sans que personne le remarque : c'est le genre de point qu'un Bear Scan relève en dix jours, avec un état des lieux priorisé que ton équipe peut appliquer.

Écrit par
Flavien Métivier
Expert PHP/Symfony indépendant depuis 2014, ancien CTO de Kartable et d'Extrabat. Plus de 80 projets menés, de l'audit à la migration.
Mon parcours