Le cache HTTP, vu par Symfony
Le tour complet du cache HTTP côté Symfony : l'API sur la réponse, l'attribut #[Cache], le reverse proxy maison et le découpage ESI.
Cacher une page, ce n'est pas la ranger dans un coin de ton application. C'est inscrire dans les en-têtes de la réponse à quelles conditions on peut la réutiliser, puis laisser un cache — ton navigateur, un reverse proxy, un CDN — s'en servir à ta place. Du coup Symfony ne cache presque rien lui-même : tout son travail est de te laisser exprimer ces conditions clairement, sans bricoler des chaînes d'en-têtes à la main. Et si tu n'as encore ni Varnish ni CDN devant toi, il t'en prête un, écrit en PHP.
Cache-Control, ETag ou Vary dans le cours Reverse Proxy, CDN & Cache. Rassure-toi, on ne va pas tout refaire : ici on reste côté Symfony — quelles méthodes appeler, quel attribut poser, comment HttpCache tourne. Pour la sémantique HTTP elle-même, c'est le cours réseau qui fait référence.
Deux modèles, une question
Tout le cache HTTP se ramène à une question : « cette réponse est-elle encore bonne ? ». Il y a deux façons d'y répondre.
La réponse porte une durée de vie (
max-age, s-maxage, Expires). Tant qu'elle n'est pas écoulée, le cache sert sa copie sans contacter le serveur. Le moins de latence possible.
La réponse porte un validateur (
ETag, Last-Modified). Quand la copie est périmée, le cache demande au serveur « a-t-elle changé ? » ; le serveur répond 304 Not Modified sans renvoyer le corps si rien n'a bougé. On économise la bande passante, pas la requête.
Les quatre sections qui suivent montent en abstraction : d'abord l'API bas niveau sur Response, puis l'attribut déclaratif #[Cache], puis le reverse proxy HttpCache qui consomme ces en-têtes, et enfin l'ESI pour cacher une page par morceaux.
L'API de cache de Response
Public ou privé, une durée de vie, une empreinte pour revalider : chaque intention de cache tient en un appel.
La classe Response expose une API fluide pour tous les en-têtes de cache. Tu ne construis jamais la chaîne Cache-Control toi-même : chaque méthode ajoute la bonne directive.
use Symfony\Component\HttpFoundation\Response; $response = new Response($html); $response->setPublic(); // Cache-Control: public $response->setMaxAge(3600); // max-age=3600 (caches privés) $response->setSharedMaxAge(7200); // s-maxage=7200 (caches partagés) + public return $response;
setPublic() autorise le stockage dans un cache partagé (reverse proxy, CDN) : la réponse sera servie à tout le monde. setPrivate() la réserve au cache du client (navigateur). Une page personnalisée (panier, profil) doit rester private.
max-age s'applique à tous les caches, s-maxage ne concerne que les caches partagés et l'emporte sur max-age pour eux. Un CDN peut ainsi garder une page 2 h (s-maxage) pendant que le navigateur ne la garde que 5 min (max-age). Appeler setSharedMaxAge() rend d'ailleurs la réponse public automatiquement.
Les validateurs
$response->setEtag(md5($article->getContent())); $response->setLastModified($article->getUpdatedAt()); $response->setPublic(); // Court-circuite tout : si la requête correspond, renvoie 304 et vide le corps if ($response->isNotModified($request)) { return $response; // 304, sans regénérer le contenu }
Un raccourci qui accepte un tableau et valide les clés :
etag, last_modified, max_age, s_maxage, public, private, must_revalidate, immutable, no_store… Une clé inconnue lève une \InvalidArgumentException.
$response->setCache([ 'public' => true, 'max_age' => 600, 's_maxage' => 3600, 'must_revalidate' => true, ]);
setImmutable() ajoute la directive immutable — le client ne revalidera jamais tant que la réponse est fraîche, même sur un rechargement. Réservé aux assets versionnés (app.abc123.js) dont l'URL change à chaque modification.
Le modèle d'expiration
Servir vite, oui — mais une page vue par tout le monde n'est pas une page vue par chacun.
L'expiration est le modèle le plus performant : tant que la réponse est fraîche, le cache la sert seul, sans aucun aller-retour vers ton application. Zéro latence côté serveur.
Un cache calcule l'âge d'une entrée (en-tête
Age) et le compare à la durée de vie déclarée. Tant que Age < s-maxage (cache partagé) ou Age < max-age (cache privé), l'entrée est fraîche et servie directement.
// Une page d'accueil qui change peu : 1 h en CDN, 5 min en navigateur public function home(): Response { $response = $this->render('home.html.twig'); $response->setSharedMaxAge(3600); // s-maxage=3600 $response->setMaxAge(300); // max-age=300 return $response; }
Expires ou max-age ?
| Directive | Forme | Portée |
|---|---|---|
Expires | date absolue | tous caches — sensible à l'horloge du client |
max-age | durée relative (s) | tous caches |
s-maxage | durée relative (s) | caches partagés uniquement, prioritaire |
Expires repose sur une date absolue et devient faux si l'horloge du client dérive. max-age/s-maxage comptent en secondes depuis la réception — insensibles à l'heure locale. Si les deux sont présents, max-age l'emporte sur Expires.
Une réponse
public avec s-maxage sera servie à l'identique à tous les visiteurs par le cache partagé. Si la page contient le nom de l'utilisateur, tout le monde verra le premier nom mis en cache. Pour ces pages : soit private, soit isole la partie personnalisée en ESI (section suivante).
L'expiration ne convient donc qu'aux réponses identiques pour un groupe de clients et dont on tolère qu'elles soient légèrement périmées. Dès qu'il faut « du frais garanti mais pas régénéré pour rien », c'est la validation qu'il faut.
Le modèle de validation
Régénérer une page coûte cher ? Laisse le serveur répondre « c'est toujours bon » en quelques octets.
La validation répond à l'autre besoin : garder une réponse à jour sans renvoyer son corps si rien n'a changé. Le cache garde sa copie même périmée, et demande au serveur de confirmer.
ETag est une empreinte du contenu (souvent un hash) : précis, détecte n'importe quel changement. Last-Modified est une date : moins précis (granularité d'une seconde) mais gratuit si tu as déjà un updatedAt. On peut poser les deux.
public function show(Article $article, Request $request): Response { $response = new Response(); $response->setEtag(md5($article->getContent())); $response->setLastModified($article->getUpdatedAt()); $response->setPublic(); // Si la requête est conditionnelle et correspond → 304, on s'arrête là if ($response->isNotModified($request)) { return $response; } // Sinon, on rend le contenu complet (coûteux) return $this->render('article/show.html.twig', ['article' => $article], $response); }
isNotModified(). Tu évites ainsi de régénérer la vue quand le client a déjà la bonne version. Le calcul de l'ETag doit rester bien moins cher que le rendu — sinon la validation ne t'apporte rien.
Elle ne s'active que sur une méthode cacheable (GET/HEAD).
If-None-Match (ETag) est prioritaire, en comparaison faible (le préfixe W/ est ignoré). If-Modified-Since (date) n'est consulté que si If-None-Match est absent. En cas de correspondance, elle appelle setNotModified() : statut 304 et corps vidé.
Observe l'échange conditionnel
Le serveur détient un article en version v3. Choisis l'ETag que le client a en cache, envoie la requête, et regarde s'il reçoit un 304 (léger) ou un 200 (corps complet). Modifie l'article pour changer l'ETag serveur.
L'attribut déclaratif #[Cache]
Les mêmes en-têtes, déclarés d'une ligne au-dessus de l'action — et un 304 servi sans même l'exécuter.
Manipuler Response à la main marche, mais l'attribut #[Cache] (namespace Symfony\Component\HttpKernel\Attribute) rend le cache déclaratif. Un listener applique ses valeurs à la réponse après le contrôleur.
use Symfony\Component\HttpKernel\Attribute\Cache; class BlogController { // public + s-maxage=3600 + max-age=600, sans toucher à la Response #[Cache(public: true, smaxage: 3600, maxage: 600)] public function list(): Response { return $this->render('blog/list.html.twig'); } }
#[Cache] se pose sur une méthode, une classe (défaut pour toutes ses actions) ou une fonction, et il est répétable. Ses paramètres : expires, maxage, smaxage, public, mustRevalidate, vary, lastModified, etag, maxStale, staleWhileRevalidate, staleIfError, noStore, if.
Response n'est pas écrasé par l'attribut. L'attribut ne fournit que des valeurs par défaut. Et noStore: true supersede public et smaxage — la réponse devient non stockable, point.
Validation sans exécuter le contrôleur
Le plus puissant : etag et lastModified acceptent une expression ou une closure évaluée avant l'action, avec accès aux attributs de requête et aux arguments résolus du contrôleur. Si la réponse est validée (304), le corps du contrôleur n'est jamais exécuté.
// L'ETag est calculé depuis l'entité résolue par le ParamConverter, // AVANT le corps de la méthode. Un 304 court-circuite tout le rendu. #[Cache(lastModified: 'article.getUpdatedAt()', etag: 'article.getId() ~ article.getUpdatedAt().getTimestamp()')] public function show(Article $article): Response { // Non exécuté si le client possède déjà la bonne version return $this->render('article/show.html.twig', ['article' => $article]); }
Le paramètre
if (expression, closure ou booléen) décide si l'attribut s'applique. Utile pour ne cacher que sous condition — par exemple ne poser des en-têtes publics que pour les visiteurs anonymes : if: 'not is_granted("IS_AUTHENTICATED")'.
Request, et le contrôleur résolu — de quoi calculer un validateur sans dupliquer la logique métier.
HttpCache — le reverse proxy de Symfony
Un reverse proxy complet en PHP, qui note dans chaque réponse ce qu'il a fait de ta requête.
Si tu n'as pas de Varnish ou de CDN devant ton app, Symfony fournit son propre reverse proxy écrit en PHP : HttpCache. Il implements HttpKernelInterface, TerminableInterface — c'est un kernel qui enveloppe le tien.
// public/index.php — on emballe le kernel dans le cache use Symfony\Component\HttpKernel\HttpCache\HttpCache; $kernel = new Kernel($env, $debug); // En prod uniquement, et derrière un vrai proxy on s'en passe if ('prod' === $env) { $kernel = new HttpCache($kernel, new Store($cacheDir)); } $response = $kernel->handle($request);
À chaque requête : méthode non sûre (POST/PUT/DELETE) →
invalidate() ; en-tête Expect ou méthode non cacheable → pass() (droit au backend, sans cache) ; Cache-Control: no-cache du client avec allow_reload → fetch() ; sinon → lookup() dans le store.
Le cœur : lookup()
| Situation | Trace | Action |
|---|---|---|
| rien en cache | miss | fetch backend, puis store |
| entrée fraîche | fresh | servie directe, sans backend |
| entrée périmée | stale | validate (GET conditionnel au backend) |
| backend répond 304 | valid | rafraîchit l'entrée et la sert |
Chaque étape est enregistrée et exposée dans l'en-tête
X-Symfony-Cache (nom par défaut, configurable). Tu y lis exactement ce que le proxy a fait : GET /: fresh, GET /: stale, valid, store, GET /: miss, store… C'est ton premier outil de debug de cache.
fresh, HttpCache n'appelle aucun listener kernel.terminate — exactement comme si un Varnish répondait devant ton app. Ne compte donc pas sur un traitement post-réponse (envoi d'e-mail différé, log) pour les pages servies depuis le cache.
Regarde le proxy travailler
Enchaîne des requêtes et observe la trace X-Symfony-Cache, l'âge de l'entrée, et si le backend est réellement sollicité. Fais expirer le TTL, poste une requête, force un rechargement.
TTL de l'entrée : 10 s — l'entrée devient stale quand son âge dépasse.
ESI — cacher une page par morceaux
Cacher la coquille d'une page une heure tout en gardant un bloc frais à chaque visite : chaque fragment vit à son rythme.
Le problème de l'expiration : une page entière partage un seul TTL. Or une page mêle souvent du contenu très cacheable (l'article) et du contenu volatil ou personnalisé (le panier, « bonjour Alice »). L'ESI (Edge Side Includes) résout ça en cachant chaque fragment séparément.
La page maître contient des balises
<esi:include src="..."/>. Le reverse proxy récupère chaque fragment via une sous-requête, avec sa propre durée de vie, et les assemble. La coquille peut vivre 1 h pendant qu'un fragment est recalculé à chaque appel.
# config/packages/framework.yaml framework: http_cache: enabled: true esi: true
{# Dans un template Twig : délègue le fragment à un contrôleur #} {# La coquille est très cacheable ; ce bloc a son propre cache #} {{ render_esi(controller('App\\Controller\\CartController::summary')) }}
Le proxy annonce sa capacité via l'en-tête
Surrogate-Capability. Quand une réponse contient <esi:include>, Symfony pose Surrogate-Control: content="ESI/1.0" pour signaler qu'il faut la traiter. Si aucun proxy capable n'est là, render_esi se rabat sur un rendu en ligne immédiat — le code reste identique en dev.
Le fragment décide de son cache
public function summary(): Response { $response = $this->render('cart/_summary.html.twig'); $response->setSharedMaxAge(10); // ce bloc : 10 s seulement return $response; }
ResponseCacheStrategy combine leurs en-têtes. La durée de vie de la réponse maître est ramenée à celle du fragment le moins cacheable. Un fragment private ou no-store rend la page entière non partageable — sauf s'il est isolé derrière un ESI, justement.
render() inclut le fragment dans le même cycle PHP (toujours recalculé avec la page). render_esi() le confie au proxy, qui le cache indépendamment. N'utilise l'ESI que si le fragment a réellement un cycle de cache différent — sinon c'est de la complexité gratuite.
Synthèse — choisir sa stratégie
Le mode d'emploi : quoi cacher, comment le déclarer, et avec quel outil selon ton contexte.
Le cache HTTP dans Symfony, c'est un empilement de choix cohérents. Voici comment les trancher.
Expiration (
s-maxage) quand tu tolères un léger décalage et veux zéro latence : pages de contenu, listes, pages marketing.Validation (
ETag/Last-Modified) quand il faut du frais garanti mais que régénérer coûte cher, et que calculer le validateur est bon marché : fiches détaillées, API.Les deux ensemble : sers frais tant que le TTL tient, puis revalide au lieu de tout régénérer.
| Besoin | Outil |
|---|---|
| Poser un en-tête précis, logique conditionnelle | Response (API impérative) |
| Cache déclaratif par défaut sur une action/classe | #[Cache] |
| Éviter le rendu quand le client a la bonne version | #[Cache(etag: ...)] ou isNotModified() |
| Un proxy en PHP sans infra dédiée | HttpCache + Store |
| Une page cacheable avec un bloc volatil | ESI + render_esi() |
HttpCache est parfait pour démarrer, en dev, ou sur une petite app : aucune infra, tout en PHP. Mais il partage le process PHP et ne monte pas comme un cache dédié. En production sérieuse, mets un Varnish ou un CDN devant — ils lisent exactement les mêmes en-têtes que tu as appris à poser ici. Ta configuration de cache ne change pas ; seul le composant qui l'applique change. La sémantique réseau de ces proxies est détaillée dans le cours Reverse Proxy, CDN & Cache.
X-Symfony-Cache (ou l'en-tête Age, ou les traces de ton CDN). Une page que tu crois cachée mais qui affiche miss à chaque fois cache mal — souvent un Set-Cookie, un Vary trop large, ou une réponse restée private. Le cache ne ment jamais dans ses en-têtes.
public que ce qui est réellement identique pour plusieurs clients. En cas de doute, reste private — un cache trop agressif qui fuite des données personnelles est bien pire qu'un cache absent.