<?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/cache/</id>
  <title>Kévin THÉRAGE | Expert Symfony Developer - Cache</title>
  <subtitle><![CDATA[Kévin THÉRAGE – Expert Symfony Developer. Technical blog on Symfony, PHP, web development with tutorials, best practices and expert advice for developers.]]></subtitle>
  <link href="https://ktherage.github.io/tags/cache/atom.xml" rel="self" type="application/atom+xml" />
  <link href="https://ktherage.github.io/tags/cache/" rel="alternate" type="text/html" />
  <updated>2026-06-17T12:58:06+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/the-cache-that-silently-wasnt/</id>
    <title>The Cache That Didn&#039;t Cache: A Symfony Serialization Story</title>
    <published>2026-06-10T00:00:00+00:00</published>
    <link href="https://ktherage.github.io/blog/the-cache-that-silently-wasnt/" rel="alternate" type="text/html" />
    <content type="html">
      <![CDATA[<p>I hit a bug where caching HTTP responses seemed to work perfectly according to the logs, yet no files ever appeared in <code translate="no">var/http_cache/</code>. No files. No errors. Just pure silence.</p>
<h2 id="the-context">The Context</h2>
<p>I am building an MCP server to expose a RAG and avoid redundant API calls during development. The filesystem cache sits between the application and the external API I use to construct my RAG.</p>
<p>The HTTP client chain follows a classic decorator pattern (from top to bottom in the decoration chain):</p>
<pre class="mermaid d-flex flex-column m-2 justify-content-center align-items-center">
flowchart TD
    A[Symfony\Component\HttpClient\HttpClient\ScopingHttpClient] --&gt; B
    B["CachedHttpClient (stratégie de cache perso)"] --&gt; C
    C["LoggedHttpClient (stratégie de logging perso)"] --&gt; D
    D["Symfony\Component\HttpClient\HttpClient::create()"]
</pre>
<p><code translate="no">CachedHttpClient</code> combines Symfony's <code translate="no">ScopingHttpClient</code> (for scoping and authenticating with the API) with a <code translate="no">FilesystemAdapter</code> to persist HTTP responses in <code translate="no">var/http_cache/</code>. A <code translate="no">CachedResponse</code> class implements <code translate="no">ResponseInterface</code> so that cached responses look identical to fresh ones.</p>
<h2 id="the-symptom">The Symptom</h2>
<pre><code translate="no">app.DEBUG: Storing Response to cache with key c3f36f73afae200bb284436334b6647f.
app.DEBUG: Response stored to cache with key c3f36f73afae200bb284436334b6647f.</code></pre>
<p>The debug logs confirmed the caching attempts. The reality: <code translate="no">var/http_cache/</code> remained completely empty.</p>
<div class="d-flex flex-column m-2 justify-content-center align-items-center">
    <iframe src="https://giphy.com/embed/NTur7XlVDUdqM" width="480" height="274" frameborder="0" class="giphy-embed" allowfullscreen></iframe>
    <p><a href="https://giphy.com/gifs/trump-consequences-NTur7XlVDUdqM">via GIPHY</a>
</p></div>
<h2 id="the-analysis">The Analysis</h2>
<h3 id="1-the-filesystemadapter-the-red-herring-that-helped-me-understand">1. The <code translate="no">FilesystemAdapter</code> — The Red Herring That Helped Me Understand</h3>
<p>So the question at that point was: Why? Why isn't it saving my responses to the cache?</p>
<h3 id="1-1-is-there-an-issue-with-the-filesystem">1.1. Is there an issue with the filesystem?</h3>
<p>I initially thought it was coming from <code translate="no">Symfony\Component\Cache\Adapter\FilesystemAdapter</code>, and digging into the <code translate="no">vendor</code> files revealed the following path:</p>
<pre class="mermaid d-flex flex-column m-2 justify-content-center align-items-center">
flowchart TD
    A["Symfony\Component\Cache\Adapter\FilesystemAdapter::save()"] --&gt; B
    B["Symfony\Component\Cache\Traits\AbstractAdapterTrait::save()"] --&gt; C
    C["Symfony\Component\Cache\Adapter\AbstractAdapter::commit()"] --&gt; D
    D["Symfony\Component\Cache\Traits\FilesystemTrait::doSave()"] --&gt; E
    E["Symfony\Component\Cache\Marshaller\DefaultMarshaller::marshall()"] --&gt; F{"calls serialize()"}
    F["Symfony\Component\Cache\Traits\FilesystemCommonTrait::write()"]
</pre>
<p><code translate="no">Symfony\Component\Cache\Traits\FilesystemTrait::doSave()</code> called <code translate="no">Symfony\Component\Cache\Marshaller\DefaultMarshaller::marshall()</code>, which used PHP's native <code translate="no">serialize()</code> before handing the return value over to <code translate="no">Symfony\Component\Cache\Traits\FilesystemCommonTrait::write()</code>.</p>
<p>Looking closely at the code, I noticed that <code translate="no">FilesystemCommonTrait::write()</code> runs PHP's <code translate="no">mkdir()</code> prefixed with an <code translate="no">@</code> operator, silencing any directory creation errors. Meaning, if there was an issue with my cache directory, it would be suppressed entirely. I tried running <code translate="no">chmod -R 777 var/http_cache/</code>, but to no avail.</p>
<h3 id="1-2-is-there-an-issue-with-serialization">1.2. Is there an issue with serialization?</h3>
<p>The only thing left to check was whether the issue came from serialization itself. I wrote a minimal reproduction script to figure it out:</p>
<pre><code class="language-bash hljs bash" translate="no">docker compose <span class="hljs-built_in">exec</span> cli sh -c <span class="hljs-string">"php -r '
require \"/srv/vendor/autoload.php\";

use App\HTTP\CachedResponse;
use Symfony\Component\HttpClient\HttpClient;

\$client = HttpClient::create();
\$response = \$client-&gt;request(\"GET\", \"https://some.api.com/foo\", [
    \"headers\" =&gt; [
        \"User-Agent\" =&gt; \"Test\",
    ],
]);

\$cached = new CachedResponse(\$response);
try {
    \$serialized = serialize(\$cached);
    echo \"Serialization OK\\n\";
} catch (\Exception \$e) {
    echo \"Serialization FAILED: \" . \$e-&gt;getMessage() . \"\\n\";
}
' 2&gt;&amp;1
# Output:
# Serialization FAILED: Serialization of 'Closure' is not allowed</span></code></pre>
<p>The failure happens during <code translate="no">serialize()</code> — long before any file operations occur. The <code translate="no">CachedResponse</code> object contained non-serializable data, which in my case turned out to be a <code translate="no">Closure</code>.</p>
<h3 id="2-locating-the-unserializable-element">2. Locating the Unserializable Element</h3>
<p>The new question now was: Where on earth could a <code translate="no">Closure</code> be lurking inside my <code translate="no">CachedResponse</code>?</p>
<h3 id="2-1-cachedresponse">2.1. CachedResponse</h3>
<p>This class is quite straightforward and is built from an instance of <code translate="no">Symfony\Contracts\HttpClient\ResponseInterface</code>.</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">HTTP</span>;

<span class="hljs-keyword">use</span> <span class="hljs-title">Symfony</span>\<span class="hljs-title">Contracts</span>\<span class="hljs-title">HttpClient</span>\<span class="hljs-title">ResponseInterface</span>;

<span class="hljs-keyword">final</span> <span class="hljs-class"><span class="hljs-keyword">class</span> <span class="hljs-title">CachedResponse</span> <span class="hljs-keyword">implements</span> <span class="hljs-title">ResponseInterface</span>
</span>{
    <span class="hljs-keyword">private</span> int $statusCode;
    <span class="hljs-keyword">private</span> <span class="hljs-keyword">array</span> $headers;
    <span class="hljs-keyword">private</span> string $content;
    <span class="hljs-keyword">private</span> <span class="hljs-keyword">array</span> $toArray;
    <span class="hljs-keyword">private</span> <span class="hljs-keyword">array</span> $info;

    <span class="hljs-keyword">public</span> <span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">__construct</span><span class="hljs-params">(ResponseInterface $response)</span>
    </span>{
        <span class="hljs-keyword">$this</span>-&gt;statusCode = $response-&gt;getStatusCode();
        <span class="hljs-keyword">$this</span>-&gt;headers = $response-&gt;getHeaders();
        <span class="hljs-keyword">$this</span>-&gt;content = $response-&gt;getContent();
        <span class="hljs-keyword">$this</span>-&gt;toArray = $response-&gt;toArray();
        <span class="hljs-keyword">$this</span>-&gt;info = $response-&gt;getInfo();
    }

    <span class="hljs-comment">// ...</span>
}</code></pre>
<h3 id="2-2-process-of-elimination">2.2. Process of Elimination</h3>
<p>By a process of elimination, only <code translate="no">getInfo()</code> could contain this kind of data, because:</p>
<ul>
<li><code translate="no">statusCode</code>: returns an <code translate="no">int</code> corresponding to the HTTP status code.</li>
<li><code translate="no">headers</code>: returns HTTP headers, which are fundamentally arrays of <code translate="no">int</code> or <code translate="no">string</code>.</li>
<li><code translate="no">content</code>: returns the response body, which is just a <code translate="no">string</code>, so no <code translate="no">Closure</code> there.</li>
<li><code translate="no">toArray</code>: would have thrown an exception already if the <code translate="no">content</code> wasn't valid JSON.</li>
</ul>
<p>So, there had to be something unexpected inside <code translate="no">getInfo()</code>. Inspecting <code translate="no">getInfo()</code> revealed the culprit via this script:</p>
<pre><code class="language-bash hljs bash" translate="no">docker compose <span class="hljs-built_in">exec</span> cli php -r <span class="hljs-string">'
require "/srv/vendor/autoload.php";
use Symfony\Component\HttpClient\HttpClient;
\$client = HttpClient::create();
\$response = \$client-&gt;request("GET", "https://api.github.com/repos/symfony/symfony/pulls/64552", [
    "headers" =&gt; ["Accept" =&gt; "application/vnd.github+json", "User-Agent" =&gt; "Test"],
]);
foreach (\$response-&gt;getInfo() as \$k =&gt; \$v) {
    if (\$v instanceof \\Closure) echo "\$k =&gt; Closure\\n";
}
'</span>
<span class="hljs-comment"># Output:</span>
pause_handler =&gt; Closure</code></pre>
<p>Symfony’s HTTP client includes a <code translate="no">pause_handler</code> key inside <code translate="no">getInfo()</code>, which contains a <code translate="no">Closure</code> used internally for retry logic (handling <code translate="no">429 Too Many Requests</code> with <code translate="no">Retry-After</code>). PHP, however, cannot serialize a <code translate="no">Closure</code>.</p>
<h3 id="3-why-the-failure-was-silent">3. Why the Failure Was Silent</h3>
<p>Three layers of code completely masked the root cause:</p>
<p><strong>Layer 1 — Ignored Return Value / My Mistake</strong></p>
<p>My mistake was failing to check the return value of the <code translate="no">save()</code> function. I hadn't realized at the time that it returns a boolean indicating whether the item was successfully stored.</p>
<p>So I went from:</p>
<pre><code class="language-php hljs php" translate="no"><span class="hljs-keyword">$this</span>-&gt;cache-&gt;save($cacheItem); <span class="hljs-comment">// returns false, ignored</span>
<span class="hljs-keyword">$this</span>-&gt;logger-&gt;debug(<span class="hljs-string">"Response stored to cache with key {$key}."</span>);</code></pre>
<p>To:</p>
<pre><code class="language-php hljs php" translate="no">$saved = <span class="hljs-keyword">$this</span>-&gt;cache-&gt;save($cacheItem);
<span class="hljs-keyword">$this</span>-&gt;logger-&gt;debug(<span class="hljs-string">"Cache save: {result}"</span>, [<span class="hljs-string">'result'</span> =&gt; $saved ? <span class="hljs-string">'success'</span> : <span class="hljs-string">'FAILED'</span>]);</code></pre>
<p>This gave me more relevant logs, showing a <code translate="no">Cache save: FAILED</code> message on every single attempt. The <code translate="no">save()</code> method was failing, meaning my cache had never actually worked.</p>
<p><strong>Layer 2 — Silent Exception Handling in the Marshaller</strong></p>
<p><code translate="no">serialize()</code> was failing silently because, inside <code translate="no">Symfony\Component\Cache\Marshaller\DefaultMarshaller::marshall()</code>, Symfony catches serialization exceptions by default and populates an array with the IDs of failed serializations.</p>
<p>Here is a simplified version of that function:</p>
<pre><code class="language-php hljs php" translate="no"><span class="hljs-keyword">public</span> <span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">marshall</span><span class="hljs-params">(array $values, ?array &amp;$failed)</span>: <span class="hljs-title">array</span>
</span>{
    $serialized = $failed = [];

    <span class="hljs-keyword">foreach</span> ($values <span class="hljs-keyword">as</span> $id =&gt; $value) {
        <span class="hljs-keyword">try</span> {
            $serialized[$id] = serialize($value);
        } <span class="hljs-keyword">catch</span> (\<span class="hljs-keyword">Exception</span> $e) {
            <span class="hljs-keyword">if</span> (<span class="hljs-keyword">$this</span>-&gt;throwOnSerializationFailure) {
                <span class="hljs-keyword">throw</span> <span class="hljs-keyword">new</span> \ValueError($e-&gt;getMessage(), <span class="hljs-number">0</span>, $e);
            }
            $failed[] = $id;
        }
    }

    <span class="hljs-keyword">return</span> $serialized;
}</code></pre>
<p>With <code translate="no">throwOnSerializationFailure</code> defaulting to <code translate="no">false</code>, exceptions are swallowed. The failed cache key goes into <code translate="no">$failed</code>, but no warning is ever logged.</p>
<p><strong>Layer 3 — Serializing a Mixed Data Array Fully</strong></p>
<p>Storing a mixed data array completely in the cache was another mistake of mine — <em>though only half mine, as I didn't anticipate the <code translate="no">Closure</code> inside <code translate="no">getInfo()</code></em>. In hindsight, not everything is worth keeping.</p>
<h2 id="the-fix">The Fix</h2>
<p><strong>Validate cache operation results:</strong></p>
<pre><code class="language-php hljs php" translate="no"><span class="hljs-keyword">if</span> (!\<span class="hljs-keyword">$this</span>-&gt;cache-&gt;save(\$cacheItem)) {
    \<span class="hljs-keyword">$this</span>-&gt;logger-&gt;warning(<span class="hljs-string">'Failed to save response to cache'</span>, [<span class="hljs-string">'key'</span> =&gt; \$key]);
    <span class="hljs-keyword">return</span>;
}
\<span class="hljs-keyword">$this</span>-&gt;logger-&gt;debug(<span class="hljs-string">"Response stored to cache with key {\$key}."</span>);</code></pre>
<p><strong>Filter out <code translate="no">\Closure</code> instances from <code translate="no">getInfo()</code>:</strong></p>
<pre><code class="language-php hljs php" translate="no">\<span class="hljs-keyword">$this</span>-&gt;info = array_filter(
    \$response-&gt;getInfo(),
    <span class="hljs-keyword">static</span> fn (\$v) =&gt; !\$v <span class="hljs-keyword">instanceof</span> \Closure
);</code></pre>
<h2 id="prevention">Prevention</h2>
<p>This bug wasn't an issue with Symfony's cache system — it was working exactly as designed. The failure came from three aligned oversights:</p>
<ol>
<li>
<p><strong>Forgetting to check return values</strong><br>
Methods return values for a reason. Treat methods returning a <code translate="no">bool</code> like <code translate="no">save()</code> as contracts.</p>
</li>
<li>
<p><strong>Neglecting silent exception handling</strong><br>
Frameworks sometimes prioritize silence over visibility. Know where to flip the switch for verbosity (<code translate="no">throwOnSerializationFailure: true</code> in dev environments).</p>
</li>
<li>
<p><strong>Assuming <code translate="no">getInfo()</code> only contains scalar data</strong><br>
Internal mechanics like <code translate="no">pause_handler</code> can leak into metadata. Always validate what you cache.</p>
</li>
</ol>]]>
    </content>
  </entry>
</feed>
