<?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/serialisation/</id>
  <title>Kévin THÉRAGE | Expert Symfony Developer - Sérialisation</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/fr/tags/serialisation/atom.xml" rel="self" type="application/atom+xml" />
  <link href="https://ktherage.github.io/fr/tags/serialisation/" rel="alternate" type="text/html" />
  <updated>2026-06-17T12:58:07+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/le-cache-qui-ne-fonctionnait-pas/</id>
    <title>Le cache qui ne fonctionnait pas : une histoire de sérialisation chez Symfony</title>
    <published>2026-06-10T00:00:00+00:00</published>
    <link href="https://ktherage.github.io/fr/blog/le-cache-qui-ne-fonctionnait-pas/" rel="alternate" type="text/html" />
    <content type="html">
      <![CDATA[<p>J'ai eu un bug où la mise en cache des réponses HTTP semblait fonctionner d'après les logs, pourtant aucun fichier n'apparaissait dans <code translate="no">var/http_cache/</code>. Pas de fichiers. Pas d'erreurs. Juste du silence.</p>
<h2 id="le-contexte">Le Contexte</h2>
<p>Je construis un serveur MCP pour exposer un RAG et éviter les appels API redondants pendant le développement. Le cache filesystem se place entre l'application et l'API externe que j'utilise pour construire mon RAG.</p>
<p>La chaîne de clients HTTP suit un pattern decorator classique (du plus haut au plus bas dans la chaine de décoration):</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> combine <code translate="no">ScopingHttpClient</code> de Symfony (pour le scoping et l'authentification auprès de l'API) avec un <code translate="no">FilesystemAdapter</code> pour persister les réponses HTTP dans <code translate="no">var/http_cache/</code>. Une classe <code translate="no">CachedResponse</code> implémente <code translate="no">ResponseInterface</code> pour que les réponses mises en cache ressemblent aux réponses fraîches.</p>
<h2 id="le-symptome">Le Symptôme</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>Les logs debug confirmaient les tentatives de mise en cache. Réalité : <code translate="no">var/http_cache/</code> restait vide.</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="l-analyse">L'analyse</h2>
<h3 id="1-le-filesystemadapter-la-fausse-piste-qui-m-a-aide-a-comprendre">1. Le <code translate="no">FilesystemAdapter</code> la fausse piste qui m'a aidé a comprendre</h3>
<p>Donc la question qui ce posait à ce moment était, Pourquoi ? Pourquoi ça ne sauvegarde pas mes reponses en cache ?</p>
<h3 id="1-1-y-a-t-il-un-probleme-avec-le-systeme-de-fichiers">1.1. Y-a-t-il un problème avec le système de fichiers ?</h3>
<p>J'ai d'abord pensé que ça venait de <code translate="no">Symfony\Component\Cache\Adapter\FilesystemAdapter</code> et en fouillant dans les fichiers du dossier <code translate="no">vendor</code> j'ai pu constater ce qui suit :</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> appelait <code translate="no">Symfony\Component\Cache\Marshaller\DefaultMarshaller::marshall()</code> qui lui utilisait <code translate="no">serialize()</code> de PHP avant d'en fournir le retour à <code translate="no">Symfony\Component\Cache\Traits\FilesystemCommonTrait::write()</code>.</p>
<p>En ouvrant la fonction, j'ai constaté que <code translate="no">FilesystemCommonTrait::write()</code> executais la fonction <code translate="no">mkdir()</code> de PHP préfixée d'un <code translate="no">@</code> qui supprimais les erreurs lié a la création du répertoire. Donc s'il y avait un problème avec mon repertoire de cache, il serait tû tout simplement. J'ai donc essayé de lancer un <code translate="no">chmod -R 777 var/http_cache/</code> mais en vain.</p>
<h3 id="1-2-y-a-t-il-un-probleme-avec-la-serialisation">1.2. Y-a-t-il un problème avec la serialisation ?</h3>
<p>Il ne me restais plus qu'à voir si le problème pouvait venir de la serialisation. J'ai donc créé un script de reproduction minimaliste pour comprendre :</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
# Sortie :
# Serialization FAILED: Serialization of 'Closure' is not allowed</span></code></pre>
<p>L'échec se produit pendant <code translate="no">serialize()</code> — bien avant les opérations de fichier. L'objet <code translate="no">CachedResponse</code> contenait des données non sérialisables, dans mon cas une <code translate="no">Closure</code>.</p>
<h3 id="2-localiser-l-element-non-serialisable">2. Localiser l'élément non sérialisable</h3>
<p>La nouvelle question maintenant, où est-ce que je peut avoir une <code translate="no">Closure</code> dans ma <code translate="no">CachedResponse</code>.</p>
<h3 id="2-1-cachedresponse">2.1. CachedResponse</h3>
<p>Cette classe est plutôt simple et est construite à partir d'une instance de <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-procedons-par-elimination">2.2. Procédons par élimination</h3>
<p>En procédant par élimination, il ne nous reste que <code translate="no">getInfo()</code> qui peut avoir ce genre de choses à l'interieur puisque :</p>
<ul>
<li><code translate="no">statusCode</code>: retourne un <code translate="no">int</code> qui corresponds au code de statut HTTP qui est aussi un entier.</li>
<li><code translate="no">headers</code>: retourne les entêtes HTTP qui ne sont basiquement que des tableaux d'<code translate="no">int</code> ou <code translate="no">string</code>.</li>
<li><code translate="no">content</code>: retourne le corps de la réponse qui n'est qu'une <code translate="no">string</code> donc aucune <code translate="no">Closure</code> là-dedans.</li>
<li><code translate="no">toArray</code>: aurait renvoyé une exception si le <code translate="no">content</code> n'avait pas été encodé en JSON.</li>
</ul>
<p>Donc il doit y avoir quelque chose d'étrange que je n'avais pas anticipé dans <code translate="no">getInfo()</code> et inspecter <code translate="no">getInfo()</code> a révélé le coupable ce que j'ai fais via ce 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"># Sortie :</span>
pause_handler =&gt; Closure</code></pre>
<p>Le client HTTP de Symfony inclut une clé <code translate="no">pause_handler</code> dans <code translate="no">getInfo()</code> contenant une <code translate="no">Closure</code> utilisée en interne pour la logique de retry (gestion des <code translate="no">429 Too Many Requests</code> avec <code translate="no">Retry-After</code>) or PHP ne peut pas sérialiser les <code translate="no">Closure</code>.</p>
<h3 id="3-pourquoi-l-echec-etait-silencieux">3. Pourquoi l'échec était silencieux</h3>
<p>Trois couches ont occulté la rééle cause :</p>
<p><strong>Couche 1 — Valeur de retour ignorée / Mon erreure</strong></p>
<p>Mon erreure a été de ne pas vérifier le retour de la fonction <code translate="no">save()</code> qui, je ne l'avais pas noté à ce moment là, retourne un booléen indiquant si l'enregistrement a bien été effectué.</p>
<p>Je suis donc passé de :</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>à:</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>Ce qui m'a permis d'avoir des logs plus pertinent avec un message <code translate="no">Cache save: FAILED</code> qui était affiché à chaque tentatives.
La méthodes <code translate="no">save()</code> ne fonctionnait pas et donc mon cache n'avais jamais fonctionné.</p>
<p><strong>Couche 2 — Gestion d'exception silencieuse dans le marshallage</strong></p>
<p><code translate="no">serialize()</code> plantais silencieusement parce que dans <code translate="no">Symfony\Component\Cache\Marshaller\DefaultMarshaller::marshall()</code> en interne et par défaut Symfony attrape les exceptions de sérialisation et peuple un tableau d'id avec les serialisations échouées.</p>
<p>Voici une version simplifiée de la fonction :</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>Avec <code translate="no">throwOnSerializationFailure</code> par défaut à <code translate="no">false</code>, les exceptions sont avalées. La clé de cache échouée va dans <code translate="no">$failed</code>, mais aucun avertissement n'est émis.</p>
<p><strong>Couche 3 — Sérialisation complète d'un tableau de donneés mixte</strong></p>
<p>Stocker complètement un tableau de données mixte dans le cache a été une autre de mes erreurs, <em>quoique à moitié la mienne je n'avais pas anticipé la <code translate="no">Closure</code> dans <code translate="no">getInfo()</code></em>, avec le recule tout n'est pas peut-être pas bon a conserver.</p>
<h2 id="la-correction">La Correction</h2>
<p><strong>Valider les résultats de l'opération de cache:</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>Filtrer les <code translate="no">\Closure</code> de <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">Prévention</h2>
<p>Ce bug n'était pas un problème avec le cache de Symfony — il fonctionnait comme conçu. L'échec venait de trois négligences alignées :</p>
<ol>
<li>
<p><strong>Oublier de vérifier les valeurs de retour</strong><br>
Les méthodes retournent des valeurs pour une raison. Traite les méthodes avec <code translate="no">bool</code> comme <code translate="no">save()</code> comme des contrats.</p>
</li>
<li>
<p><strong>Négliger la gestion d'exception silencieuse</strong><br>
Les frameworks priorisent parfois le silence sur la visibilité. Saisis où basculer la verbosité (<code translate="no">throwOnSerializationFailure: true</code> en dev).</p>
</li>
<li>
<p><strong>Supposer que <code translate="no">getInfo()</code> ne contient que des données scalaires</strong><br>
Des internals comme <code translate="no">pause_handler</code> peuvent fuir dans les métadonnées. Valide toujours ce que tu caches.</p>
</li>
</ol>]]>
    </content>
  </entry>
</feed>
