<?xml version="1.0" encoding="utf-8"?>
<?xml-stylesheet type="text/xsl" href="https://ktherage.github.io/fr/xsl/atom.xsl" media="all"?>
<feed xmlns="http://www.w3.org/2005/Atom" xml:lang="fr">
  <id>https://ktherage.github.io/fr/tags/api-platform/</id>
  <title>Kévin THÉRAGE | Symfony Lead Developer (Expert Symfony 7 Certified) - API Platform</title>
  <subtitle><![CDATA[Kévin THÉRAGE – Symfony Lead Developer, Expert Symfony 7 Certified. Technical blog on Symfony, PHP, web development with tutorials, best practices and expert advice for developers.]]></subtitle>
  <link href="https://ktherage.github.io/fr/tags/api-platform/atom.xml" rel="self" type="application/atom+xml" />
  <link href="https://ktherage.github.io/fr/tags/api-platform/" rel="alternate" type="text/html" />
  <updated>2026-09-02T18:19:32+00:00</updated>
  <author>
    <name>Kévin THÉRAGE</name>
    <uri>https://ktherage.github.io/</uri>
  </author>
  <entry xml:lang="fr">
    <id>https://ktherage.github.io/fr/blog/2026/symfony-session-vs-http-cache/</id>
    <title>Symfony tue silencieusement votre cache HTTP dès qu&#039;une session démarre</title>
    <published>2026-09-02T00:00:00+00:00</published>
    <link href="https://ktherage.github.io/fr/blog/2026/symfony-session-vs-http-cache/" rel="alternate" type="text/html" />
    <content type="html">
      <![CDATA[<p>J'ai découvert à mes dépens que Symfony possède un listener de sécurité qui écrase votre directive <code translate="no">Cache-Control</code> avec <code translate="no">private, max-age=0, must-revalidate</code> dès qu'une session PHP est démarrée pendant la requête.</p>
<p>Plus important, il existe une échappatoire officielle lorsqu'on sait que la réponse peut malgré tout être mise en cache.</p>
<p>Si vous vous êtes déjà battu avec ce header :</p>
<pre><code class="language-http hljs http" translate="no"><span class="hljs-attribute">Cache-Control</span>: max-age=0, must-revalidate, private, s-maxage=86400</code></pre>
<p>alors que votre code demande clairement une réponse publique, cet article est pour vous.</p>
<h2 id="le-symptome">Le symptôme</h2>
<p>Avec API Platform 3.4 et cette configuration, on définit les headers de cache d'une ressource :</p>
<pre><code class="language-php hljs php" translate="no"><span class="hljs-comment">#[ApiResource(</span>
    cacheHeaders: [
        <span class="hljs-string">'etag'</span> =&gt; <span class="hljs-keyword">true</span>,
        <span class="hljs-string">'max_age'</span> =&gt; <span class="hljs-number">86400</span>,
        <span class="hljs-string">'shared_max_age'</span> =&gt; <span class="hljs-number">86400</span>,
        <span class="hljs-string">'vary'</span> =&gt; [<span class="hljs-string">'Accept'</span>],
    ]
)]
<span class="hljs-class"><span class="hljs-keyword">class</span> <span class="hljs-title">Article</span>
</span>{
}</code></pre>
<p>On s'attend à quelque chose comme :</p>
<pre><code class="language-http hljs http" translate="no"><span class="hljs-attribute">Cache-Control</span>: max-age=86400, public, s-maxage=86400
<span class="hljs-attribute">ETag</span>: "abc123"</code></pre>
<p>Mais dans certaines situations, on observe :</p>
<pre><code class="language-http hljs http" translate="no"><span class="hljs-attribute">Cache-Control</span>: max-age=0, must-revalidate, private, s-maxage=86400</code></pre>
<p>Dans cette situation, même si le <code translate="no">s-maxage</code> est présent dans le header, il devient inutile pour un cache partagé : la réponse est passée en <code translate="no">private</code> et le cache partagé passera à côté de la réponse sans tenir compte de la durée définie dans <code translate="no">s-maxage</code>.</p>
<p>Pas d'erreur, pas de log : votre configuration API Platform était correcte, elle a simplement été modifiée plus tard dans le cycle HTTP.</p>
<h2 id="comment-je-suis-tombe-la-dessus-des-tests-behat-sur-le-cache">Comment je suis tombé là-dessus : des tests Behat sur le cache</h2>
<p>Je ne suis pas tombé là-dessus en lisant le code source par curiosité un dimanche après-midi. Je suis tombé là-dessus en écrivant des tests Behat sur le cache HTTP de l'API.</p>
<p>J'écrivais donc des scénarios Behat pour couvrir le comportement de cache de nos endpoints API, dans le genre :</p>
<pre><code class="language-gherkin hljs gherkin" translate="no"><span class="hljs-keyword">Scenario</span>: Les articles publiés sont mis en cache publiquement
  <span class="hljs-keyword">Given</span> je suis authentifié en tant qu'utilisateur
  <span class="hljs-keyword">When</span> j'envoie une requête <span class="hljs-string">"GET"</span> vers <span class="hljs-string">"/api/articles"</span>
  <span class="hljs-keyword">Then</span> la réponse a le statut 200
  <span class="hljs-keyword">And</span> le header de réponse <span class="hljs-string">"cache-control"</span> contient <span class="hljs-string">"public"</span>
  <span class="hljs-keyword">And</span> le header de réponse <span class="hljs-string">"cache-control"</span> contient <span class="hljs-string">"max-age=86400"</span>
  <span class="hljs-keyword">And</span> le header de réponse <span class="hljs-string">"cache-control"</span> contient <span class="hljs-string">"s-maxage=86400"</span></code></pre>
<p>À l'exécution du test, il a échoué. Pas de <code translate="no">public</code> dans le header, mais un <code translate="no">max-age=0, must-revalidate, private, s-maxage=86400</code> à la place.</p>
<p>C'est là que j'ai commencé à regarder ce qui pouvait modifier la réponse après qu'API Platform ait correctement configuré ses headers.</p>
<h2 id="le-coupable-abstractsessionlistener-avec-une-priorite-a-1000">Le coupable : <code translate="no">AbstractSessionListener</code> avec une priorité à <code translate="no">-1000</code></h2>
<p>Le responsable est le listener <code translate="no">Symfony\Component\HttpKernel\EventListener\AbstractSessionListener</code>. Son rôle ne se limite pas à sauvegarder la session : quand une session est démarrée pendant une requête, Symfony transforme par défaut la réponse en réponse privée non cachable, une mesure de sécurité appréciable pour éviter de mettre en cache et de redistribuer par erreur des données privées propres à un utilisateur à travers une erreur de configuration du cache HTTP.</p>
<p>Deux détails expliquent pourquoi ça surprend autant.</p>
<p><strong>Il passe très tard.</strong> Dans Symfony 6.4+, le listener s'abonne à <code translate="no">kernel.response</code> avec une priorité de <code translate="no">-1000</code>, explicitement pour s'exécuter parmi les derniers listeners de réponse après API Platform, après vos propres subscribers <em>qui n'ont que rarement une priorité aussi basse de paramétrée je présume 😅</em>.</p>
<p><strong>Il regarde si la session a été utilisée</strong>, pas juste si elle existe. Ce n'est pas <code translate="no">$request-&gt;hasSession()</code>. Le code réel :</p>
<pre><code class="language-php hljs php" translate="no"><span class="hljs-keyword">if</span> ($autoCacheControl) { <span class="hljs-comment">// cette condition va nous être utile plus tard (`true` par défaut)</span>

    $maxAge = $response-&gt;headers-&gt;hasCacheControlDirective(<span class="hljs-string">'public'</span>)
        ? <span class="hljs-number">0</span>
        : (int) $response-&gt;getMaxAge();

    $response
        -&gt;setExpires(<span class="hljs-keyword">new</span> \DateTimeImmutable(<span class="hljs-string">'+'</span>.$maxAge.<span class="hljs-string">' seconds'</span>))
        -&gt;setPrivate()
        -&gt;setMaxAge($maxAge)
        -&gt;headers-&gt;addCacheControlDirective(<span class="hljs-string">'must-revalidate'</span>);
}</code></pre>
<p>Avant même d'atteindre <code translate="no">if ($autoCacheControl)</code>, <code translate="no">AbstractSessionListener::onKernelResponse()</code> enchaîne plusieurs garde-fous — chacun peut court-circuiter avec un <code translate="no">return</code> sans toucher au <code translate="no">Cache-Control</code> :</p>
<ol>
<li><strong>Requête principale uniquement</strong> — <code translate="no">if (!$event-&gt;isMainRequest() || (!$container-&gt;has('initialized_session') &amp;&amp; !$request-&gt;hasSession())) return;</code> : les sous-requêtes sont ignorées.</li>
<li><strong>Header interne dépilé tôt</strong> — <code translate="no">$autoCacheControl = !$response-&gt;headers-&gt;has(self::NO_AUTO_CACHE_CONTROL_HEADER)</code> puis <code translate="no">remove()</code> systématique, même si la suite sortira.</li>
<li><strong>Session attachée ?</strong> — <code translate="no">if (!$request-&gt;hasSession(true)) return;</code> (le <code translate="no">true</code> cherche aussi dans les attributs, pas seulement la pile).</li>
<li><strong>Si <code translate="no">isStarted()</code> → <code translate="no">save()</code> + cookies</strong> — sauvegarde anticipée (verrous, <code translate="no">fastcgi_finish_request</code>, régénération d'ID) et gestion du <code translate="no">Set-Cookie</code> : <code translate="no">clearCookie</code> si session vide et cookie présent, <code translate="no">setCookie</code> si nouvel ID et session non vide.</li>
<li><strong>Usage réel ?</strong> — <code translate="no">if ($session instanceof Session ? 0 === $session-&gt;getUsageIndex() : !$session-&gt;isStarted()) return;</code> : une session qui existe mais n'a jamais été lue/écrite ne déclenche pas le passage en <code translate="no">private</code>.</li>
</ol>
<p>Ce n'est qu'après ces sorties que le bloc s'exécute — <code translate="no">maxAge</code> vaut <code translate="no">0</code> si la réponse était <code translate="no">public</code> sinon <code translate="no">getMaxAge()</code>, puis <code translate="no">setExpires()</code>, <code translate="no">setPrivate()</code>, <code translate="no">setMaxAge($maxAge)</code> et <code translate="no">must-revalidate</code>.</p>
<p>Fichier : <code translate="no">src/Symfony/Component/HttpKernel/EventListener/AbstractSessionListener.php</code> — <a href="https://github.com/symfony/symfony/blob/6.4/src/Symfony/Component/HttpKernel/EventListener/AbstractSessionListener.php" rel="noopener noreferrer">6.4 sur GitHub</a>.</p>
<h2 id="pourquoi-symfony-fait-ca">Pourquoi Symfony fait ça ?</h2>
<p>Une session est généralement synonyme <strong>de données dépendantes de l'utilisateur présentement connecté</strong>. Si une réponse <code translate="no">GET /api/me</code> pour l'utilisateur A devenait publiquement cachable, un utilisateur B pourrait recevoir les données de A depuis le cache partagé (comme Varnish par exemple).</p>
<p>Symfony adopte donc un comportement conservateur par défaut : session utilisée → réponse privée.</p>
<p><strong>Le simple fait d'avoir un header <code translate="no">Authorization: Bearer ...</code> ne signifie pas que Symfony a démarré une session.</strong> Le déclencheur réel de <code translate="no">AbstractSessionListener</code>, c'est l'usage effectif de la session quelque part dans la requête, pas la présence d'un token d'authentification.</p>
<p>Donc même si votre pare-feu est configuré avec l'option <code translate="no">stateless: true</code>, il est possible que votre code, à un moment donné, utilise la session utilisateur pour des raisons tout à fait légitimes, ce qui déclenche le listener.</p>
<p>La vraie question à se poser est : <strong>une session est-elle effectivement utilisée pendant cette requête</strong> via votre code, un bundle, un listener, un contrôleur ?</p>
<p>Pour le savoir, cherchez les accès qui déclenchent effectivement la session :</p>
<pre><code class="language-php hljs php" translate="no">$request-&gt;getSession()-&gt;start()
$request-&gt;getSession()-&gt;set(...)
$request-&gt;getSession()-&gt;get(...)
$request-&gt;getSession()-&gt;getFlashBag()</code></pre>
<h2 id="l-echappatoire-le-header-magique-symfony-session-noautocachecontrol">L'échappatoire : le header magique <code translate="no">Symfony-Session-NoAutoCacheControl</code></h2>
<p>Vous vous souvenez de <code translate="no">if ($autoCacheControl) {</code> ? C'est lui notre sauveur en la circonstance.</p>
<p>Parce qu'un échappatoire a été prévu pour contourner ce comportement dans le cas où l'on souhaite explicitement dire à Symfony :</p>
<blockquote>
<p>OK, je sais que tu veux me protéger mais je sais ce que je fais alors oublie ça 5 minutes tu veux ?</p>
</blockquote>
<p>Et ça passe par la vérification de la présence de la constante <code translate="no">NO_AUTO_CACHE_CONTROL_HEADER</code> dans les headers de la réponse :</p>
<pre><code class="language-php hljs php" translate="no">$autoCacheControl = !$response-&gt;headers-&gt;has(
    <span class="hljs-keyword">self</span>::NO_AUTO_CACHE_CONTROL_HEADER
);</code></pre>
<p>La constante vaut <code translate="no">Symfony-Session-NoAutoCacheControl</code>.</p>
<p>Si votre <code translate="no">Response</code> porte ce header, le listener n'applique pas son <code translate="no">private</code> automatique, puis le supprime lui-même avant d'envoyer la réponse au client :</p>
<pre><code class="language-php hljs php" translate="no">$response-&gt;headers-&gt;remove(<span class="hljs-keyword">self</span>::NO_AUTO_CACHE_CONTROL_HEADER);</code></pre>
<p>C'est un signal interne au serveur, pas un header de <code translate="no">Response</code> destiné à être transmis côté client et ne doit jamais l'être.</p>
<h3 id="un-hack-non-c-est-documente">Un hack ? Non — c'est documenté</h3>
<p>Jusqu'à Symfony 7.0, toute la classe <code translate="no">AbstractSessionListener</code> était <code translate="no">@internal</code>, donc PHPStan/Psalm hurlaient si vous utilisiez <code translate="no">AbstractSessionListener::NO_AUTO_CACHE_CONTROL_HEADER</code>.</p>
<p>La PR <a href="https://github.com/symfony/symfony/pull/53057" rel="noopener noreferrer">#53057</a> — <em>[HttpKernel] Move @internal from AbstractSessionListener class to its methods and properties</em> — a retiré <code translate="no">@internal</code> de la classe (gardé sur méthodes/props), rétroportée en 6.4, pour rendre la constante officiellement utilisable. La doc Symfony 7.1 l'affiche désormais explicitement :</p>
<pre><code class="language-php hljs php" translate="no"><span class="hljs-keyword">use</span> <span class="hljs-title">Symfony</span>\<span class="hljs-title">Component</span>\<span class="hljs-title">HttpKernel</span>\<span class="hljs-title">EventListener</span>\<span class="hljs-title">AbstractSessionListener</span>;

$response-&gt;headers-&gt;set(AbstractSessionListener::NO_AUTO_CACHE_CONTROL_HEADER, <span class="hljs-string">'true'</span>);</code></pre>
<p>Source : <a href="https://symfony.com/doc/current/http_cache.html#http-caching-and-user-sessions" rel="noopener noreferrer">symfony.com/doc/current/http_cache.html#http-caching-and-user-sessions</a></p>
<h3 id="comment-l-utiliser">Comment l'utiliser</h3>
<p>Si vous contrôlez la <code translate="no">Response</code> :</p>
<pre><code class="language-php hljs php" translate="no"><span class="hljs-keyword">use</span> <span class="hljs-title">Symfony</span>\<span class="hljs-title">Component</span>\<span class="hljs-title">HttpKernel</span>\<span class="hljs-title">EventListener</span>\<span class="hljs-title">AbstractSessionListener</span>;

$response-&gt;headers-&gt;set(
    AbstractSessionListener::NO_AUTO_CACHE_CONTROL_HEADER,
    <span class="hljs-string">'true'</span>
);</code></pre>
<p>Dans une application API Platform, un subscriber <code translate="no">kernel.response</code> scope proprement le comportement :</p>
<pre><code class="language-php hljs php" translate="no"><span class="hljs-meta">&lt;?php</span>

<span class="hljs-keyword">declare</span>(strict_types=<span class="hljs-number">1</span>);

<span class="hljs-keyword">namespace</span> <span class="hljs-title">App</span>\<span class="hljs-title">EventSubscriber</span>;

<span class="hljs-keyword">use</span> <span class="hljs-title">Symfony</span>\<span class="hljs-title">Component</span>\<span class="hljs-title">EventDispatcher</span>\<span class="hljs-title">EventSubscriberInterface</span>;
<span class="hljs-keyword">use</span> <span class="hljs-title">Symfony</span>\<span class="hljs-title">Component</span>\<span class="hljs-title">HttpKernel</span>\<span class="hljs-title">Event</span>\<span class="hljs-title">ResponseEvent</span>;
<span class="hljs-keyword">use</span> <span class="hljs-title">Symfony</span>\<span class="hljs-title">Component</span>\<span class="hljs-title">HttpKernel</span>\<span class="hljs-title">EventListener</span>\<span class="hljs-title">AbstractSessionListener</span>;
<span class="hljs-keyword">use</span> <span class="hljs-title">Symfony</span>\<span class="hljs-title">Component</span>\<span class="hljs-title">HttpKernel</span>\<span class="hljs-title">KernelEvents</span>;

<span class="hljs-keyword">final</span> <span class="hljs-class"><span class="hljs-keyword">class</span> <span class="hljs-title">BypassSessionCacheSubscriber</span> <span class="hljs-keyword">implements</span> <span class="hljs-title">EventSubscriberInterface</span>
</span>{
    <span class="hljs-keyword">public</span> <span class="hljs-keyword">static</span> <span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">getSubscribedEvents</span><span class="hljs-params">()</span>: <span class="hljs-title">array</span>
    </span>{
        <span class="hljs-keyword">return</span> [
            <span class="hljs-comment">// AbstractSessionListener = -1000, on doit passer avant</span>
            KernelEvents::RESPONSE =&gt; [<span class="hljs-string">'onKernelResponse'</span>, <span class="hljs-number">-100</span>],
        ];
    }

    <span class="hljs-keyword">public</span> <span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">onKernelResponse</span><span class="hljs-params">(ResponseEvent $event)</span>: <span class="hljs-title">void</span>
    </span>{
        <span class="hljs-keyword">if</span> (!$event-&gt;isMainRequest()) {
            <span class="hljs-keyword">return</span>;
        }

        $request = $event-&gt;getRequest();

        <span class="hljs-comment">// Scoper par chemin — jamais globalement</span>
        <span class="hljs-keyword">if</span> (!str_starts_with($request-&gt;getPathInfo(), <span class="hljs-string">'/api/'</span>)) {
            <span class="hljs-keyword">return</span>;
        }

        $event-&gt;getResponse()-&gt;headers-&gt;set(
            AbstractSessionListener::NO_AUTO_CACHE_CONTROL_HEADER,
            <span class="hljs-string">'true'</span>
        );
    }
}</code></pre>
<p><code translate="no">-100</code> n'a rien de magique : il est simplement supérieur à <code translate="no">-1000</code>, donc ce subscriber s'exécute avant <code translate="no">AbstractSessionListener</code>.</p>
<h3 id="le-piege-a-eviter-absolument">Le piège à éviter absolument</h3>
<p>Ne posez pas ce header globalement sur toute réponse <code translate="no">GET</code> :</p>
<pre><code class="language-php hljs php" translate="no"><span class="hljs-comment">// À ne pas faire sans réfléchir au contenu de la réponse</span>
<span class="hljs-keyword">if</span> ($request-&gt;isMethodSafe()) {
    $response-&gt;headers-&gt;set(
        AbstractSessionListener::NO_AUTO_CACHE_CONTROL_HEADER,
        <span class="hljs-string">'true'</span>
    );
}</code></pre>
<p><code translate="no">GET /api/me</code>, <code translate="no">GET /api/cart</code> ou <code translate="no">GET /api/orders</code> ne doivent pas devenir publiquement cachables simplement parce qu'elles utilisent <code translate="no">GET</code>.</p>
<p>La bonne question : <strong>cette réponse est-elle identique pour plusieurs utilisateurs ?</strong></p>
<p>Dans mon cas, ça l'était. Mais si ce n'est pas le cas, ne contournez pas cette sécurité de Symfony.</p>
<p>De plus, il est préférable de <em>whitelister</em> les chemins qui ont droit de contourner cette sécurité pour éviter tout problème de fuite de données lié au cache.</p>
<h2 id="tl-dr">TL;DR</h2>
<pre><code class="language-text" translate="no">#[ApiResource(cacheHeaders: [...])]
        → API Platform génère une réponse cachable
        → kernel.response
        → AbstractSessionListener (-1000)
        → session utilisée ? oui
        → Cache-Control devient private</code></pre>
<p>Symfony fait ça volontairement pour éviter qu'une réponse liée à une session finisse accidentellement dans un cache partagé.</p>
<p>Si vous savez avec certitude que la réponse peut être cachée malgré l'usage de la session :</p>
<pre><code class="language-php hljs php" translate="no">$response-&gt;headers-&gt;set(
    AbstractSessionListener::NO_AUTO_CACHE_CONTROL_HEADER,
    <span class="hljs-string">'true'</span>
);</code></pre>
<p>posé sur la <code translate="no">Response</code>, jamais sur la <code translate="no">Request</code>, puis supprimé automatiquement par le listener avant l'envoi.</p>
<p>Et si une leçon est à retenir en plus du header lui-même : <strong>testez vos headers de cache comme n'importe quel autre comportement observable de votre API.</strong></p>]]>
    </content>
  </entry>
</feed>
