<?xml version="1.0" encoding="utf-8"?>
<?xml-stylesheet type="text/xsl" href="https://ktherage.github.io/xsl/atom.xsl" media="all"?>
<feed xmlns="http://www.w3.org/2005/Atom" xml:lang="en">
  <id>https://ktherage.github.io/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/tags/api-platform/atom.xml" rel="self" type="application/atom+xml" />
  <link href="https://ktherage.github.io/tags/api-platform/" rel="alternate" type="text/html" />
  <updated>2026-09-02T18:19:31+00:00</updated>
  <author>
    <name>Kévin THÉRAGE</name>
    <uri>https://ktherage.github.io/</uri>
  </author>
  <entry xml:lang="en">
    <id>https://ktherage.github.io/blog/2026/symfony-session-vs-http-cache/</id>
    <title>Symfony Silently Kills Your HTTP Cache When a Session Starts</title>
    <published>2026-09-02T00:00:00+00:00</published>
    <link href="https://ktherage.github.io/blog/2026/symfony-session-vs-http-cache/" rel="alternate" type="text/html" />
    <content type="html">
      <![CDATA[<p>I learned the hard way that Symfony has a security listener that silently overwrites your <code translate="no">Cache-Control</code> with <code translate="no">private, max-age=0, must-revalidate</code> the moment a PHP session is started during the request.</p>
<p>More importantly, there is an official escape hatch when you know the response is safe to cache anyway.</p>
<p>If you have ever fought this 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>while your code clearly asked for a public response, this one is for you.</p>
<h2 id="the-symptom">The symptom</h2>
<p>With API Platform 3.4 and this configuration, you define cache headers for a resource:</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>You expect something like:</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>But in some situations, you get:</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>Even though <code translate="no">s-maxage</code> is present in the header, it becomes useless for a shared cache: the response is now <code translate="no">private</code> and the shared cache will skip it entirely, ignoring the <code translate="no">s-maxage</code> duration you set.</p>
<p>No error, no log: your API Platform configuration was correct — it was simply modified later in the HTTP cycle.</p>
<h2 id="how-i-found-it-behat-tests-on-the-cache">How I found it: Behat tests on the cache</h2>
<p>I didn't find this by reading source code out of curiosity on a Sunday afternoon. I found it while writing Behat tests for the HTTP cache on our API.</p>
<p>I was writing scenarios to cover cache behavior on our API endpoints, something like:</p>
<pre><code class="language-gherkin hljs gherkin" translate="no"><span class="hljs-keyword">Scenario</span>: Published articles are publicly cached
  <span class="hljs-keyword">Given</span> I am authenticated as a user
  <span class="hljs-keyword">When</span> I send a <span class="hljs-string">"GET"</span> request to <span class="hljs-string">"/api/articles"</span>
  <span class="hljs-keyword">Then</span> the response status code should be 200
  <span class="hljs-keyword">And</span> the response header <span class="hljs-string">"cache-control"</span> should contain <span class="hljs-string">"public"</span>
  <span class="hljs-keyword">And</span> the response header <span class="hljs-string">"cache-control"</span> should contain <span class="hljs-string">"max-age=86400"</span>
  <span class="hljs-keyword">And</span> the response header <span class="hljs-string">"cache-control"</span> should contain <span class="hljs-string">"s-maxage=86400"</span></code></pre>
<p>At execution, the test failed. No <code translate="no">public</code> in the header — just a <code translate="no">max-age=0, must-revalidate, private, s-maxage=86400</code> instead.</p>
<p>That's when I started looking at what could modify the response after API Platform had correctly set its headers.</p>
<h2 id="the-culprit-abstractsessionlistener-at-1000">The culprit: <code translate="no">AbstractSessionListener</code> at <code translate="no">-1000</code></h2>
<p>The responsible party is <code translate="no">Symfony\Component\HttpKernel\EventListener\AbstractSessionListener</code>. Its job goes beyond saving the session: when a session is started during a request, Symfony transforms the response into a private, uncacheable one by default — a security measure to avoid accidentally caching and redistributing user-specific private data across a shared cache misconfiguration.</p>
<p>Two details explain why this catches everyone off guard.</p>
<p><strong>It runs very late.</strong> In Symfony 6.4+, the listener subscribes to <code translate="no">kernel.response</code> at priority <code translate="no">-1000</code>, explicitly to execute among the very last response listeners — after API Platform, after your own subscribers <em>that rarely have such a low priority set, I'd presume</em>.</p>
<p><strong>It checks whether the session was actually used</strong>, not just whether it exists. It's not <code translate="no">$request-&gt;hasSession()</code>. The real code:</p>
<pre><code class="language-php hljs php" translate="no"><span class="hljs-keyword">if</span> ($autoCacheControl) { <span class="hljs-comment">// this condition will be useful later (`true` by default)</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>Before it ever reaches <code translate="no">if ($autoCacheControl)</code>, <code translate="no">AbstractSessionListener::onKernelResponse()</code> runs through several guard clauses — each can <code translate="no">return</code> early without touching <code translate="no">Cache-Control</code>:</p>
<ol>
<li><strong>Main request only</strong> — <code translate="no">if (!$event-&gt;isMainRequest() || (!$container-&gt;has('initialized_session') &amp;&amp; !$request-&gt;hasSession())) return;</code> : sub-requests are ignored.</li>
<li><strong>Internal header popped early</strong> — <code translate="no">$autoCacheControl = !$response-&gt;headers-&gt;has(self::NO_AUTO_CACHE_CONTROL_HEADER)</code> then unconditional <code translate="no">remove()</code>, even if the method returns right after.</li>
<li><strong>Session attached?</strong> — <code translate="no">if (!$request-&gt;hasSession(true)) return;</code> (<code translate="no">true</code> also checks request attributes).</li>
<li><strong>If <code translate="no">isStarted()</code> → <code translate="no">save()</code> + cookie handling</strong> — early save (locks, <code translate="no">fastcgi_finish_request</code>, ID regeneration) and <code translate="no">Set-Cookie</code> management: <code translate="no">clearCookie</code> if session empty but cookie present, <code translate="no">setCookie</code> if new ID and session not empty.</li>
<li><strong>Actually used?</strong> — <code translate="no">if ($session instanceof Session ? 0 === $session-&gt;getUsageIndex() : !$session-&gt;isStarted()) return;</code> : a session that exists but was never read/written does not force <code translate="no">private</code>.</li>
</ol>
<p>Only after these early exits does the block run — <code translate="no">maxAge</code> is <code translate="no">0</code> if the response was <code translate="no">public</code> else <code translate="no">getMaxAge()</code>, then <code translate="no">setExpires()</code>, <code translate="no">setPrivate()</code>, <code translate="no">setMaxAge($maxAge)</code> and <code translate="no">must-revalidate</code>.</p>
<p>File: <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 on GitHub</a>.</p>
<h2 id="why-symfony-does-this">Why Symfony does this</h2>
<p>A session generally means <strong>user-specific data is involved</strong>. If a <code translate="no">GET /api/me</code> response for user A became publicly cacheable, user B could receive A's data from the shared cache (Varnish, for example).</p>
<p>Symfony defaults to a conservative behavior: session used → private response.</p>
<p><strong>Having an <code translate="no">Authorization: Bearer ...</code> header does not mean Symfony started a session.</strong> The actual trigger for <code translate="no">AbstractSessionListener</code> is effective session usage somewhere in the request — not the presence of an auth token.</p>
<p>So even if your firewall is configured with <code translate="no">stateless: true</code>, your code — at some point — might still use the session for entirely legitimate reasons, triggering the listener.</p>
<p>The real question to ask yourself is: <strong>is a session actually being used during this request</strong> — via your code, a bundle, a listener, a controller?</p>
<p>To find out, look for the accesses that actually start the 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="the-if-autocachecontrol-escape-hatch">The <code translate="no">if ($autoCacheControl)</code> escape hatch</h2>
<p>Remember <code translate="no">if ($autoCacheControl) {</code>? That's our escape route.</p>
<p>Because Symfony planned an escape hatch to bypass this behavior when you explicitly want to tell the framework:</p>
<blockquote>
<p>OK, I know you want to protect me, but I know what I'm doing — so can you ignore this for a minute?</p>
</blockquote>
<p>It goes through checking for the presence of the <code translate="no">NO_AUTO_CACHE_CONTROL_HEADER</code> constant in the response headers:</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>The constant resolves to <code translate="no">'Symfony-Session-NoAutoCacheControl'</code>.</p>
<p>If your <code translate="no">Response</code> carries this header, the listener skips the forced <code translate="no">private</code>, then removes the header itself before sending the response to the 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>This is an internal server signal, never a header destined for the client.</p>
<h3 id="is-it-a-hack-no-it-is-documented">Is it a hack? No — it is documented</h3>
<p>Until Symfony 7.0, the entire <code translate="no">AbstractSessionListener</code> class was <code translate="no">@internal</code>, so PHPStan/Psalm complained when you referenced <code translate="no">AbstractSessionListener::NO_AUTO_CACHE_CONTROL_HEADER</code>.</p>
<p>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> — removed <code translate="no">@internal</code> from the class (kept on methods/props), backported to 6.4, making this constant officially usable. Symfony 7.1 docs now show it explicitly:</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="how-to-use-it">How to use it</h3>
<p>If you control the <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>In an API Platform application, a <code translate="no">kernel.response</code> subscriber scopes the behavior properly:</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-comment">// AbstractSessionListener = -1000, we need to run before it</span>
        <span class="hljs-keyword">return</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">// Scope it by path — never globally</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> is nothing magical: it simply has higher priority than <code translate="no">-1000</code>, so this subscriber runs before <code translate="no">AbstractSessionListener</code>.</p>
<h3 id="the-pitfall-to-absolutely-avoid">The pitfall to absolutely avoid</h3>
<p>Do not set this header globally on every safe <code translate="no">GET</code> response:</p>
<pre><code class="language-php hljs php" translate="no"><span class="hljs-comment">// Do not do this without thinking about the response content</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>, or <code translate="no">GET /api/orders</code> must not become publicly cacheable simply because they use <code translate="no">GET</code>.</p>
<p>The right question: <strong>is this response identical for multiple users?</strong></p>
<p>In my case, it was. But if it isn't, do not bypass this Symfony safety mechanism.</p>
<p>Also, prefer whitelisting specific paths that are allowed to bypass this protection, rather than applying it broadly.</p>
<h2 id="tl-dr">TL;DR</h2>
<pre><code class="language-text" translate="no">#[ApiResource(cacheHeaders: [...])]
        → API Platform generates a cacheable response
        → kernel.response
        → AbstractSessionListener (-1000)
        → session used? yes
        → Cache-Control becomes private</code></pre>
<p>Symfony does this intentionally to prevent a session-bound response from accidentally ending up in a shared cache.</p>
<p>If you know with certainty the response can be cached despite session usage:</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>set on the <code translate="no">Response</code>, never the <code translate="no">Request</code>, then automatically removed by the listener before sending.</p>
<p>And if one lesson is to remember beyond the header itself: <strong>test your cache headers like any other observable behavior of your API.</strong></p>]]>
    </content>
  </entry>
</feed>
