← Tous les cours / Symfony — Cache HTTP Cours
00 — VUE D'ENSEMBLE

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.

💡 Tu as peut-être déjà croisé 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.

Expiration
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.
Validation
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.
Response (API)
#[Cache]
HttpCache
ESI

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.

01 — L'API RESPONSE

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;
public vs private
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-maxage : 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
}
setCache() — tout en un
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,
]);
💡 immutable : 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.
02 — EXPIRATION

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.

Fraîcheur = durée de vie − âge
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 ?

DirectiveFormePortée
Expiresdate absoluetous caches — sensible à l'horloge du client
max-agedurée relative (s)tous caches
s-maxagedurée relative (s)caches partagés uniquement, prioritaire
⚠️ Préfère les durées relatives : 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.
Le piège de la personnalisation
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.

03 — VALIDATION

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.

Client : If-None-Match: "v3"
Serveur compare l'ETag
304 (vide) ou 200
ETag vs Last-Modified
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);
}
💡 Le bon ordre : pose les validateurs avant le rendu coûteux, et sors dès 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.
Comment isNotModified() décide
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.

04 — L'ATTRIBUT #[CACHE]

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');
    }
}
Cibles et répétition
#[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.
⚠️ Le contrôleur a le dernier mot : un en-tête déjà posé par le contrôleur sur la 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]);
}
La condition « if »
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")'.
💡 Expression vs closure : l'expression (chaîne ExpressionLanguage) est concise et lisible dans l'attribut ; la closure permet une logique arbitraire. Les deux reçoivent les attributs de requête, la Request, et le contrôleur résolu — de quoi calculer un validateur sans dupliquer la logique métier.
05 — HTTPCACHE

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);
L'arbre de décision de handle()
À 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_reloadfetch() ; sinon → lookup() dans le store.

Le cœur : lookup()

SituationTraceAction
rien en cachemissfetch backend, puis store
entrée fraîchefreshservie directe, sans backend
entrée périméestalevalidate (GET conditionnel au backend)
backend répond 304validrafraîchit l'entrée et la sert
La trace X-Symfony-Cache
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.
⚠️ terminate() sur un hit : quand la réponse est servie 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.

06 — ESI & FRAGMENTS

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.

Le principe
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')) }}
Négociation Surrogate
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;
}
⚠️ Le plus restrictif gagne : quand une page embarque des fragments, 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.
💡 ESI vs sous-requête simple : 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.
07 — SYNTHÈSE

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 ou validation ?
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.
BesoinOutil
Poser un en-tête précis, logique conditionnelleResponse (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éeHttpCache + Store
Une page cacheable avec un bloc volatilESI + render_esi()
HttpCache ou un vrai proxy ?
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.
💡 Le réflexe de debug : lis 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.
⚠️ La règle d'or : ne rends 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.