<?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/blog/2026/</id>
  <title>Kévin THÉRAGE | Symfony Lead Developer (Expert Symfony 7 Certified) - 2026</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/blog/2026/atom.xml" rel="self" type="application/atom+xml" />
  <link href="https://ktherage.github.io/fr/blog/2026/" rel="alternate" type="text/html" />
  <updated>2026-08-28T15:17:57+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/building-a-rag-powered-code-review-assistant/</id>
    <title>Construire un assistant de code review RAG avec PHP, Ollama et Qdrant</title>
    <published>2026-08-10T00:00:00+00:00</published>
    <link href="https://ktherage.github.io/fr/blog/2026/building-a-rag-powered-code-review-assistant/" rel="alternate" type="text/html" />
    <content type="html">
      <![CDATA[<p>Les <a href="https://fr.wikipedia.org/wiki/Grand_mod%C3%A8le_de_langage" rel="noopener noreferrer">LLM</a> sont excellents pour produire des code reviews qui sonnent juste. Mais « sonner juste » n'est pas la même chose qu'être utiles. Une review qui vous dit de « corrige le Code Style » est correcte mais inutile — chaque projet applique Code Style qui peut différer.</p>
<p>Symfony a plus de <strong>20 ans de code reviews publiques</strong> sur <a href="https://github.com/" rel="noopener noreferrer">GitHub</a>. Chaque PR mergée contient des commentaires de <a href="https://github.com/nicolas-grekas" rel="noopener noreferrer">nicolas-grekas</a>, <a href="https://github.com/stof" rel="noopener noreferrer">stof</a>, <a href="https://github.com/dunglas" rel="noopener noreferrer">dunglas</a>, <a href="https://github.com/xabbuh" rel="noopener noreferrer">xabbuh</a>, et des dizaines d'autres reviewers de la core team et de contributeurs. C'est une mine d'or de patterns de review spécifiques au domaine : quels arguments convainquent, quels patterns sont rejetés, ce que la communauté considère comme du bon code Symfony.</p>
<p>Le problème ? Personne n'avait construit de moteur de recherche pour l'exploiter. Alors je l'ai fais 🤣.</p>
<h2 id="le-lore-derriere-cette-idee-folle">Le lore derrière cette idée folle</h2>
<p>Cette histoire prends sa source au <a href="https://live.symfony.com/" rel="noopener noreferrer">Symfony Live de Paris</a>. Comme vous l'imaginez, celui de cette année était très orienté IA. J'ai vu bon nombre de Talks en parlé et j'ai voulu jouer un peu avec cette nouveautée mais jusque là, je n'avais pas de cas concrét.</p>
<p>J'y ai assisté au un Talk de <a href="https://github.com/lyrixx" rel="noopener noreferrer">Grégoire Pineau</a> ou il expliquais comment avec <a href="https://ai.symfony.com/" rel="noopener noreferrer">Symfony AI</a>, <a href="https://clickhouse.com/fr" rel="noopener noreferrer">Clickhouse</a> et <a href="https://redirection.io/" rel="noopener noreferrer">redirection.io</a> il avait mené à bien la migration d'un site E-Commerce en réduissant la perte de trafic.</p>
<p>Plus tard est arrivé le <a href="https://symfony.com/blog/new-in-symfony-8-1-http-less-symfony-applications" rel="noopener noreferrer">Console Bundle</a>.</p>
<p>J'avais déjà demandé à un LLM de relire les modifications que j'avais faites <em>(sur des projets perso bien sûr)</em> et, comme vous vous en doutez, j'ai obtenu des conseils du genre <code translate="no">pensez à utiliser l'injection de dépendances</code>, <code translate="no">peut-être extraire cette logique dans un service</code> ou <code translate="no">pense a vérifier le code style</code>. Ces retours sont techniquements corrects, mais surtout universellements applicables et complètements génériques. Bref, rien qui ne puisse être corrigé avec de bons outils et un peu de rigeur.</p>
<p>Sortant du Symfony Live, m'est venu une idée un peu dingue.
Et si je pouvais demander au même LLM : </p>
<blockquote>
<p>« Review ce code comme le ferait stof »</p>
</blockquote>
<p>J'aurais des retours ultra pointu et un code qui en ressortira grandi.</p>
<p>Ou</p>
<blockquote>
<p>« Review ce code comme le ferait n'importe quel contributeur Symfony »</p>
</blockquote>
<p>J'aurais alors le point de vu de l'ensemble de la communauté sur le code que je viens de créé. </p>
<p>Ou bien même</p>
<blockquote>
<p>« Review ce code comme le ferait n'importe quel membre de la core team Symfony »</p>
</blockquote>
<p>J'aurais un panel d'experts à ma disposition pour m'expliquer ce qui ne vas pas dans ce que j'ai fait.</p>
<p>C'est ce que fait <strong>Symfony Reviewer MCP</strong> : un <a href="https://fr.wikipedia.org/wiki/G%C3%A9n%C3%A9ration_%C3%A0_enrichissement_contextuel" rel="noopener noreferrer">moteur de recherche sémantique (RAG)</a> sur l'ensemble des code reviews historiques de Symfony accessibles via l'API <a href="https://docs.github.com/en/rest" rel="noopener noreferrer">GitHub</a>, exposé via le <a href="https://fr.wikipedia.org/wiki/Model_Context_Protocol" rel="noopener noreferrer">Model Context Protocol (MCP)</a> le tout avec <a href="https://ai.symfony.com/" rel="noopener noreferrer">Symfony AI</a> et dans une application <a href="https://symfony.com/blog/new-in-symfony-8-1-http-less-symfony-applications" rel="noopener noreferrer">Symfony HTTP-Less</a>.</p>
<p>Je l'ai construit en <a href="https://www.php.net/releases/8.5/" rel="noopener noreferrer">PHP 8.5</a> avec <a href="https://symfony.com/" rel="noopener noreferrer">Symfony 8.1</a>, utilisant <a href="https://ollama.com/" rel="noopener noreferrer">Ollama</a> en local pour la vectorisation avec un model issu de <a href="https://huggingface.co/" rel="noopener noreferrer">huggingface.co</a> (embeddinggemma-300m, 768 dimensions) et <a href="https://qdrant.tech/" rel="noopener noreferrer">Qdrant</a> comme base de donnée vectorielle.</p>
<p>Aucun GPU requis si on accepte la contrepartie, l'indexation complète des reviews historiques s'est exécutée sur le CPU de ma machine <em>pendant plusieurs jours</em> 😅. Pour un projet ponctuel, ce compromis m'a semblé largement acceptable et "cost-efficient".</p>
<p>Comment ça fonctionne ?</p>
<h2 id="architecture-globale">Architecture globale</h2>
<p>L'architecture globale se découpe en deux gros blocs :</p>
<ol>
<li><strong>La génération du RAG :</strong> <ol>
<li>Avec la récupération des données et leurs mise en cache</li>
<li>Avec la génération du dataset et l'indexation dans <a href="https://qdrant.tech/" rel="noopener noreferrer">Qdrant</a></li>
</ol>
</li>
<li><strong>Le serveur MCP</strong></li>
</ol>
<h3 id="la-generation-du-rag">La génération du RAG</h3>
<p>Voici le pipeline de génération RAG complet :</p>
<p>Ne vous inquiétez pas si ce schéma paraît dense, je vais parcourir chaque étape du pipeline dans le reste de l'article, depuis la récupération des reviews GitHub jusqu'à la recherche sémantique.</p>
<pre class="mermaid d-flex flex-column m-2 justify-content-center align-items-center">
flowchart TD
    A["API GitHub (symfony/symfony)"] --&gt; B["PullsFetcher (PR mergées uniquement)"]
    B --&gt; C["ReviewsFetcher (commentaires + réponses)"]
    C --&gt; D["DatasetGenerator (var/dataset/pull-{id}.txt)"]
    D --&gt; E["Builder::build"]
    E --&gt; F["Ollama (embeddinggemma-300m)"]
    F --&gt; G["Qdrant (collection: reviews)"]
</pre>
<h4>Récupération des données</h4>
<p>Avant que toute cette mécanique de décorateurs HTTP ait de l'importance, il faut d'abord parcourir <a href="https://docs.github.com/en/rest" rel="noopener noreferrer">l'API GitHub</a> et décider ce qui mérite d'être gardé.</p>
<p><code translate="no">PullsFetcher</code> pagine <code translate="no">GET /repos/symfony/symfony/pulls?state=all&amp;per_page=100</code>, en ne gardant que les PR dont <code translate="no">merged_at</code> n'est pas nul — inutile d'entraîner le système sur des idées rejetées. Plutôt que de paginer aveuglément jusqu'à tomber sur une page vide, il envoie d'abord une unique requête <code translate="no">HEAD</code> et lit le nombre total de pages directement dans le <a href="https://docs.github.com/en/rest/using-the-rest-api/using-pagination-in-the-rest-api" rel="noopener noreferrer">header <code translate="no">Link</code></a>. Cet appel <code translate="no">HEAD</code> est d'ailleurs exactement la raison d'être de <code translate="no">BLACKLISTED_PATTERN</code> : c'est une requête de découverte, pas quelque chose qui mérite d'être caché 365 jours.</p>
<p><code translate="no">ReviewsFetcher</code> parcourt ensuite <a href="https://docs.github.com/en/rest/pulls/comments" rel="noopener noreferrer"><code translate="no">GET /repos/symfony/symfony/pulls/{id}/comments</code></a> pour chaque PR et reconstruit le véritable arbre de conversation — les commentaires parents avec leurs réponses attachées. Le piège : l'API GitHub ne garantit pas l'ordre des commentaires. Si une réponse arrive avant son parent, <code translate="no">ReviewsFetcher</code> la met de côté dans un pool temporaire (<code translate="no">$repliesTempPool</code>) au lieu de la perdre, et la rattache dès que le parent est trouvé. Un petit détail de tenue de registre, mais sans lui, n'importe quel thread où trois personnes se disputent sur tabs vs espaces dans le désordre perdrait silencieusement des réponses.</p>
<h5>La chaîne de décorateurs HTTP : Logging &amp; Caching</h5>
<h6>L'architecture</h6>
<p>Les deux fetchers passent par la même petite <a href="https://symfony.com/doc/current/http_client.html#decorating-the-client" rel="noopener noreferrer">chaîne de décorateurs HTTP</a> :</p>
<pre class="mermaid d-flex flex-column m-2 justify-content-center align-items-center">
flowchart TD
    A["GithubHttpClient (scoping + auth Bearer)"] --&gt; B["CachedHttpClient (cache filesystem, TTL 365j)"]
    B --&gt; C["LoggedHttpClient (logging structuré)"]
    C --&gt; D["HttpClient::create() (Symfony natif)"]
</pre>
<p>Chaque décorateur ajoute une responsabilité :</p>
<pre><code class="language-php hljs php" translate="no"><span class="hljs-keyword">final</span> readonly <span class="hljs-class"><span class="hljs-keyword">class</span> <span class="hljs-title">CachedHttpClient</span> <span class="hljs-keyword">implements</span> <span class="hljs-title">HttpClientInterface</span>, <span class="hljs-title">ResetInterface</span>
</span>{
    <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">(
        private HttpClientInterface $client,
        private FilesystemAdapter $cache,
        private LoggerInterface $logger,
        private array $blacklistedPatterns = [],
    )</span> </span>{
    }

    <span class="hljs-keyword">public</span> <span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">request</span><span class="hljs-params">(string $method, string $url, array $options = [])</span>: <span class="hljs-title">ResponseInterface</span>
    </span>{
        $pattern = array_map(<span class="hljs-keyword">static</span> fn (string $pattern): string =&gt; preg_quote($pattern, <span class="hljs-string">'#'</span>), <span class="hljs-keyword">$this</span>-&gt;blacklistedPatterns)
            |&gt; (<span class="hljs-keyword">static</span> fn ($x): string =&gt; implode(<span class="hljs-string">'|'</span>, $x))
            |&gt; (<span class="hljs-keyword">static</span> fn (string $x): string =&gt; \sprintf(<span class="hljs-string">'#^%s$#'</span>, $x))
        ;
        $httpCall = $method.<span class="hljs-string">' '</span>.$url;
        <span class="hljs-keyword">if</span> (preg_match($pattern, $httpCall, $matches)) {
            <span class="hljs-keyword">return</span> <span class="hljs-keyword">$this</span>-&gt;client-&gt;request($method, $url, $options);
        }

        $key = md5($method.$url);
        $cacheItem = <span class="hljs-keyword">$this</span>-&gt;cache-&gt;getItem($key);
        <span class="hljs-keyword">if</span> ($cacheItem-&gt;isHit()) {
            <span class="hljs-keyword">return</span> $cacheItem-&gt;get();
        }

        $response = <span class="hljs-keyword">new</span> CachedResponse(<span class="hljs-keyword">$this</span>-&gt;client-&gt;request($method, $url, $options));

        $cacheItem-&gt;set($response);
        <span class="hljs-keyword">$this</span>-&gt;cache-&gt;save($cacheItem);

        <span class="hljs-keyword">return</span> $response;
    }
}</code></pre>
<h6>Le problème de sérialisation en chemin</h6>
<p>Cette chaîne contient un piège que j'ai déjà documenté dans mon article précédent — <a href="https://symfony.com/doc/current/http_client.html#information-related-to-the-response" rel="noopener noreferrer"><code translate="no">HttpClient::getInfo()</code></a> de Symfony contient une clé <code translate="no">pause_handler</code> avec une <code translate="no">Closure</code>, impossible à sérialiser. La classe <code translate="no">CachedResponse</code> gère cela en filtrant les valeurs non sérialisables :</p>
<pre><code class="language-php hljs php" translate="no"><span class="hljs-keyword">final</span> readonly <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-comment">/** <span class="hljs-doctag">@var</span> array&lt;string, list&lt;string&gt;&gt; */</span>
    <span class="hljs-keyword">private</span> <span class="hljs-keyword">array</span> $headers;

    <span class="hljs-keyword">private</span> string $content;

    <span class="hljs-comment">/** <span class="hljs-doctag">@var</span> array&lt;string|int, mixed&gt; */</span>
    <span class="hljs-keyword">private</span> <span class="hljs-keyword">array</span> $toArray;

    <span class="hljs-comment">/** <span class="hljs-doctag">@var</span> array&lt;string|int, mixed&gt; */</span>
    <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-comment">/** <span class="hljs-doctag">@var</span> array&lt;string|int, mixed&gt; $info */</span>
        $info = $response-&gt;getInfo();
        <span class="hljs-keyword">$this</span>-&gt;info = array_filter($info, <span class="hljs-keyword">static</span> fn ($v): bool =&gt; !$v <span class="hljs-keyword">instanceof</span> \Closure);
    }
}</code></pre>
<p>Sans ce filtre, <a href="https://symfony.com/doc/current/components/cache.html" rel="noopener noreferrer"><code translate="no">FilesystemAdapter</code></a> échoue silencieusement — l'exception de sérialisation est attrapée par <code translate="no">DefaultMarshaller</code> avec <code translate="no">throwOnSerializationFailure</code> à <code translate="no">false</code>, et la clé de cache est discrètement ignorée.</p>
<h4>Génération du dataset</h4>
<p>La commande <code translate="no">BuildCommand</code> orchestre le pipeline de vectorisation :</p>
<pre><code class="language-php hljs php" translate="no"><span class="hljs-comment">#[AsCommand(</span>
    name: <span class="hljs-keyword">self</span>::NAME,
    description: <span class="hljs-string">"build a RAG over Symfony's official Github repository's code review"</span>,
    help: <span class="hljs-string">'This command is a pre-requisites for the MCP server'</span>,
)]
<span class="hljs-keyword">final</span> readonly <span class="hljs-class"><span class="hljs-keyword">class</span> <span class="hljs-title">BuildCommand</span>
</span>{
    <span class="hljs-keyword">public</span> <span class="hljs-keyword">const</span> string NAME = <span class="hljs-string">'mcp:build'</span>;

    <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">(
        private LoggerInterface $logger,
        private DatasetGenerator $datasetGenerator,
        private Builder $builder,
    )</span> </span>{
    }

    <span class="hljs-keyword">public</span> <span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">__invoke</span><span class="hljs-params">(
        #[Option<span class="hljs-params">(description: <span class="hljs-string">'Skip dataset generation and uses dataset cache'</span>, name: <span class="hljs-string">'skip-generation'</span>, shortcut: <span class="hljs-string">'G'</span>)</span>]
        bool $skipGeneration = false,
        #[Option<span class="hljs-params">(description: <span class="hljs-string">'Skip build of dataset cache'</span>, name: <span class="hljs-string">'skip-build'</span>, shortcut: <span class="hljs-string">'B'</span>)</span>]
        bool $skipBuild = false,
    )</span>: <span class="hljs-title">int</span> </span>{
        <span class="hljs-keyword">try</span> {
            <span class="hljs-keyword">if</span> (!$skipGeneration) {
                <span class="hljs-keyword">$this</span>-&gt;datasetGenerator-&gt;generate();
            }

            <span class="hljs-keyword">if</span> (!$skipBuild) {
                <span class="hljs-keyword">$this</span>-&gt;builder-&gt;build();
            }

            <span class="hljs-keyword">return</span> Command::SUCCESS;
        } <span class="hljs-keyword">catch</span> (\Throwable $throwable) {
            <span class="hljs-keyword">$this</span>-&gt;logger-&gt;error($throwable-&gt;getMessage());

            <span class="hljs-keyword">return</span> Command::FAILURE;
        }
    }
}</code></pre>
<h5>Le fichier de base</h5>
<p>Une fois les données récupérées, <code translate="no">DatasetGenerator</code> transforme chaque PR et ses reviews en fichier texte structuré :</p>
<pre><code translate="no">[PULL_REQUEST]
    id: 54321
    author: nicolas-grekas
    author_association: MEMBER
    description:
        [HttpKernel] Fix edge case in exception handling

[REVIEWS]
    [REVIEW_1234]
        replyTo:
        reviewer: stof
        reviewer_association: MEMBER
        file: src/Component/HttpKernel/Event/ExceptionEvent.php
        diff:
            @@ -88,7 +88,7 @@
             public function getThrowable(): ?\Throwable
             {
        comment:
            We should keep the original exception here,
            the wrapper is only for internal use.

        reactions:
            +1: 5
            -1: 0
            laugh: 0
            hooray: 0
            confused: 0
            heart: 0
            rocket: 0
            eyes: 0</code></pre>
<p>Ces fichiers vivent dans <code translate="no">var/dataset/pull-{id}.txt</code> et servent de vérité terrain pour la vectorisation.</p>
<h5>Intégration Qdrant</h5>
<p>La base vectorielle est câblée dans le conteneur comme <code translate="no">StoreInterface</code>, via le <code translate="no">StoreFactory</code> de <a href="https://qdrant.tech/" rel="noopener noreferrer">Qdrant</a> :</p>
<pre><code class="language-php hljs php" translate="no">-&gt;set(StoreInterface::class, Store::class)
    -&gt;autowire()
    -&gt;factory(StoreFactory::create(...))
    -&gt;args([
        <span class="hljs-string">'$collectionName'</span> =&gt; <span class="hljs-string">'reviews'</span>,
        <span class="hljs-string">'$endpoint'</span> =&gt; env(<span class="hljs-string">'QDRANT_DSN'</span>),
        <span class="hljs-string">'$httpClient'</span> =&gt; service(LoggedHttpClient::class),
        <span class="hljs-string">'$embeddingsDimension'</span> =&gt; <span class="hljs-number">768</span>,
        <span class="hljs-string">'$embeddingsDistance'</span> =&gt; <span class="hljs-string">'Cosine'</span>,
    ])

-&gt;set(VectorizerInterface::class, Vectorizer::class)
    -&gt;autowire()
    -&gt;args([
        <span class="hljs-string">'$model'</span> =&gt; <span class="hljs-string">'hf.co/ggml-org/embeddinggemma-300m-qat-q8_0-GGUF:Q8_0'</span>,
    ])</code></pre>
<p>Deux détails valent le détour. </p>
<ol>
<li>Le <code translate="no">Vectorizer</code> utilise exactement le même modèle <a href="https://ollama.com/" rel="noopener noreferrer">Ollama</a> qu'au build — un modèle d'embedding <code translate="no">hf.co/ggml-org/embeddinggemma-300m-qat-q8_0-GGUF:Q8_0</code> produisant des vecteurs à 768 dimensions.
Cette partie est extrêment importante si vous ne voulez pas vous retrouver à comparer des pommes de terre avec des choux lors de la recherche via le MCP. En effet, un vecteur généré avec un modèle précis ne peux pas être comparé avec un vecteur généré avec un autre modèle.</li>
<li>La base réutilise le décorateur <code translate="no">LoggedHttpClient</code>, donc chaque aller-retour vers Qdrant bénéficie d'un logging structuré par-dessus le client HTTP natif de Symfony.</li>
</ol>
<h5>Vectorisation et stockage</h5>
<p>C'est là que les choses se gâtent, cette partie à elle seule m'a pris des jours.</p>
<h6>Qu'est qu'il se passe à la vectorisation ?</h6>
<p>Le fichier issu de <code translate="no">var/dataset/pull-{id}.txt</code> sont lus puis envoyés a <a href="https://ollama.com/" rel="noopener noreferrer">Ollama</a> pour demander à un modèle d'embedding, dans mon cas <code translate="no">hf.co/ggml-org/embeddinggemma-300m-qat-q8_0-GGUF:Q8_0</code>, qui va en générer un vecteur de 768 dimensions <em>(chiffre qui dépends du modèle d'embedding)</em> avant d'être renvoyé à symfony-ai par Ollama pour enfin être sauvegarder dans un espace vectoriel dans <a href="https://qdrant.tech/" rel="noopener noreferrer">Qdrant</a></p>
<pre class="mermaid d-flex flex-column m-2 justify-content-center align-items-center">
sequenceDiagram
    participant AI as Symfony AI (Builder)
    participant Ollama
    participant Model as embeddinggemma-300m
    participant Qdrant

    AI-&gt;&gt;Ollama: vectorize(contenu du fichier dataset)
    Ollama-&gt;&gt;Model: inférence du modèle
    Model--&gt;&gt;Ollama: 768 valeurs flottantes
    Ollama--&gt;&gt;AI: Vector (768 dimensions)
    AI-&gt;&gt;Qdrant: add(VectorDocument)
    Qdrant--&gt;&gt;AI: confirmation
</pre>
<p>Exemple de vecteur :</p>
<pre><code class="language-bash hljs bash" translate="no">❯ ollama run hf.co/ggml-org/embeddinggemma-300m-qat-q8_0-GGUF:Q8_0 <span class="hljs-string">'Hello world !'</span>
[0.058340553,0.017256556,-0.0023928124,0.062416226,-0.019779362,-0.069838926,0.003351966,0.029903421,0.01617497,0.009088458,-0.024585545,-0.07013172,0.0077750348,0.03643439,-0.02245716,0.02035499,0.005985676,0.008291158,0.013118213,-0.074038,0.014411658,0.011837681,0.027627029,-0.008276582,0.059961967,0.018847544,0.040701613,0.020481525,0.004880007,-0.026033,0.028065553,-0.015691148,-0.06859542,-0.04025022,-0.0046045044,-0.033632968,0.01929922,0.01854895,-0.000545488,-0.37636346,0.05929959,0.0069747632,-0.026434837,0.018491298,-0.00945082,0.0009028575,0.024641853,-0.053274404,-0.043102805,0.0016308841,-0.04315744,-0.0024387056,-0.007468653,-0.03491168,-0.00315068,-0.023274362,-0.0016503683,-0.027008653,-0.0016653507,0.029124975,0.015810343,-0.020706663,0.03261976,-0.015973076,-0.0104695875,0.0027727042,0.03187403,0.25363848,0.016416604,0.027735965,0.009674886,-0.031506274,-0.01176536,-0.060261074,-0.0031871377,-0.01684568,0.029581062,-0.05600014,-0.02623269,0.04637347,-0.04044786,0.0036846441,0.008154481,0.0190515,-0.023374602,-0.011516434,0.01098124,0.008980432,-0.02016589,-0.020812696,0.046533223,0.009594602,-0.033848874,-0.046110246,0.027529772,0.029260637,-0.04092383,0.00048074988,0.01185442,0.0059498977,0.02239185,-0.0014466406,-0.00841961,-0.011190161,0.05701471,-0.015211558,-0.052781906,0.014623615,-0.0012096912,0.029300302,0.0044483966,0.028927691,-0.032093737,0.048145276,0.034030333,0.03062545,0.015509856,-0.008765529,-0.027545273,0.010858431,0.021899927,-0.008150199,0.0034826857,-0.03487983,-0.039671477,0.009751623,-0.019133179,-0.0030401512,-0.010503486,-0.017615957,0.0365356,0.001852526,-0.013896455,0.04652015,-0.049408674,0.0120107075,0.0065035336,0.0004583914,0.0074103116,-0.028435387,-0.0110742645,-0.0012466233,0.0014801751,-0.015979966,-0.028333941,0.0053472416,0.014286039,0.00054261123,0.049599133,-0.026553018,-0.021315206,0.043478087,0.032634746,0.0031313808,-0.0007238099,-0.0033655402,0.02283678,0.012112167,-0.019739753,-0.0080446005,0.018196804,0.0334649,-0.04168719,-0.0046553654,-0.007058912,-0.0104055125,-0.033779215,0.0015363622,0.051331602,0.009603074,0.0061764405,-0.0047951997,0.0055726245,-0.013255206,-0.0184209,0.025034897,0.057538535,-0.022678796,0.029314326,-0.028992262,-0.024944754,0.005440558,-0.03586656,0.00470333,0.0039566993,-0.03774347,0.007882632,-0.019097507,0.019113457,-0.024320446,-0.012655296,-0.04030833,0.0036063064,0.017062565,-0.047738973,0.03617525,0.009754858,0.01101775,-0.03881582,-0.05497514,0.018249176,-0.02227283,-0.064608485,-0.009161445,-0.010031034,-0.0095641855,0.01916363,0.01516687,0.016545013,-0.002466866,0.05094877,-0.012659827,-0.014525608,-0.023728082,0.043452837,-0.0182468,0.0171756,-0.027970072,-0.07089399,0.030868053,-0.017713076,-0.012257217,-0.010346674,0.026055504,0.0060443725,0.017072264,0.008725935,0.013845085,-0.016069857,-0.015575777,-0.024176376,0.011916211,0.023472574,0.020805132,0.023326341,-0.032767452,-0.054396667,0.013436974,0.004714595,-0.033193223,0.018227011,0.021449534,0.035102192,0.014087173,-0.012364751,-0.02923529,-0.014678346,0.020951372,-0.046659384,-0.0001321898,-0.04854372,-0.008653948,-0.02752997,-0.039902873,0.059013035,-0.023333075,-0.002939849,-0.02412675,-0.004704963,0.005708739,0.0078358585,0.015467018,-0.017394196,-0.024916349,0.0033860407,-0.005256748,-0.019880958,0.02133062,-0.01909998,-0.03368627,-0.030686421,-0.046723425,-0.009353089,0.00718895,0.03141207,-0.005678604,0.010026497,0.017099433,0.09632587,-0.049855407,0.040112875,0.03521583,0.029755611,0.016151456,-0.011640585,-0.012128548,-0.028238969,0.015068383,-0.033082347,0.01045796,0.02537935,0.035929427,-0.066318564,0.0031127778,0.0012862016,-0.0036205864,0.025082638,-0.053492177,-0.0033758928,0.011705148,-0.0033429412,0.04687452,-0.008285868,0.0009990487,-0.032646406,0.009088849,-0.0041933167,-0.046134073,0.0067272885,0.009922917,0.01473402,-0.008017513,-0.042351346,-0.026370844,-0.013365593,-0.05842642,0.0053622974,0.07431096,-0.0013185574,-0.009227675,-0.023330051,-0.027747931,-0.009491263,0.021478329,-0.0059168683,-0.021412537,0.020145044,-0.039623715,-0.0428058,0.025570398,0.03506635,-0.037845273,0.05345302,-0.0574125,-0.00028089757,0.009052639,-0.019611377,0.04223033,0.014607936,0.04443048,0.0076912907,0.007895783,-0.0042047133,-0.0071978727,-0.005284037,0.019756729,0.006555163,0.0008758057,-0.017007992,0.050635543,0.0092563275,0.02716696,0.021407066,0.14517316,-0.02369811,0.0027539528,0.03910237,0.008360229,-0.021910692,0.011394674,0.011142392,-0.0015445971,0.0025348184,0.010536188,0.020002112,-0.025976151,0.02049012,-0.02106826,-0.032985732,0.019664701,0.021935249,0.0066386443,0.017932259,0.01572093,-0.010654519,0.023060553,-0.014906989,0.006019814,0.010056607,0.058398962,-0.033474576,0.0011755938,0.009262606,-0.01099747,-0.0015662273,-0.009874551,0.008189235,0.025591813,-0.018472403,0.04004355,-0.011285104,-0.014111953,-0.0063381894,0.0005300517,-0.023887232,-0.04470202,-0.0028616937,0.014985186,-0.03294004,-0.008383296,-0.043611214,-0.008217752,0.040103037,0.014903076,-0.0021273512,0.042627018,0.0010886613,0.41363978,0.03303489,-0.026918324,-0.051881365,-0.009240305,-0.017186431,-0.064830735,0.019849097,0.033434503,-0.0071105417,0.008766244,-0.017940182,-0.03069124,0.025611496,-0.0054379674,-0.018304668,0.035152443,0.017566781,-0.03807328,0.016257798,0.023560232,0.0043043825,0.08201138,-0.012402507,0.019028682,-0.018637981,0.0073655653,0.0015441499,-0.013527856,-0.0059385803,-0.022364056,-0.02425886,0.016911663,-0.0011785801,0.0053356686,0.016516488,-0.01820115,0.0032703253,-0.012067703,0.020428859,-0.004661816,0.0019089471,0.035107043,-0.04653369,0.032357465,0.037315182,-0.018799154,0.022463702,-0.020647656,0.021579335,-0.0511682,-0.016783282,-0.03466541,-0.0018399828,0.0013000914,-0.010348332,0.0012786519,-0.04518444,0.03560613,0.002523491,-0.005807527,0.010765285,-0.023149451,0.00044260465,0.0029642652,0.0010614877,-0.008083944,-0.022398435,-0.020968962,-0.014627337,0.008090492,0.005585011,-0.03377691,-0.011848554,0.0072662574,-0.03521151,-0.032203663,0.008255807,-0.040369254,0.030274471,-0.011215091,-0.008181156,0.053159524,-0.020998636,-0.002394821,0.0028064605,0.0074562496,0.007893967,-0.007300692,0.0015053308,-0.010529569,-0.0060634767,-0.024756493,-0.03676517,0.011349936,-0.015407753,-0.009043473,0.034528915,0.017980041,-0.021671167,-0.033637524,-0.049074188,-0.010759755,-0.016900545,0.054496538,0.09080891,-0.012992101,0.02599233,-0.0011818404,0.038375963,-0.0099124005,0.010196206,0.013038416,0.0007229094,0.058817368,-0.0010059974,0.031990774,0.05380181,0.024521016,0.002847628,0.07304043,0.0017232046,-0.031101514,-0.0050771832,0.024083985,0.005650839,0.013745816,0.037950784,-0.013184445,-0.030096699,0.0072532697,0.0069977636,0.012731625,-0.03473301,0.020486254,-0.028183239,-0.043638907,0.05255927,0.040101644,0.020626077,-0.00009298259,0.03147282,0.005550194,-0.0030457666,-0.015949357,-0.019966332,0.004474357,0.0073816148,-0.0966872,-0.0014123727,0.015428014,-0.00072764594,0.02582003,0.023952637,-0.013374169,-0.024338745,0.021395741,0.012303337,0.024213506,0.013632532,-0.016449485,-0.03322197,0.0039774035,0.00541838,0.0003197519,-0.031282444,0.021476608,0.006979306,0.024495661,0.008296023,-0.036932785,-0.031137321,-0.00706068,0.024338977,0.0073112054,0.06343152,0.010950803,-0.04011533,0.0023558561,0.005737587,-0.013831569,0.025473805,-0.017996674,0.030670065,-0.021311458,-0.014061837,-0.028316947,-0.016967898,0.05437623,-0.05517407,-0.011666794,-0.064273596,0.00039994833,-0.0016417564,0.00369619,-0.004408155,-0.033399895,0.010705014,0.022728024,-0.006053165,0.0031930788,-0.010684994,-0.05090471,-0.03378601,-0.016370287,0.00020012762,-0.022603909,0.036075003,0.030441662,0.03643664,0.01663764,0.010343481,-0.00867144,-0.015162774,-0.0014251935,0.03770172,-0.013012902,0.035615146,0.00044963427,0.012939211,-0.008898151,0.04329554,0.006962741,0.047645073,-0.058727764,0.0069460804,0.027805299,-0.0022572207,0.03155984,0.007940954,0.025537886,0.026445614,-0.01529072,0.01621024,0.0069643836,-0.013095124,-0.0015153071,-0.013846497,-0.0054590567,0.10567172,0.024595099,-0.021427441,-0.017892607,0.029084895,-0.044227537,-0.020952923,0.0037034317,-0.053684484,-0.026559578,0.0031811807,-0.0022820174,-0.07499727,-0.06748456,-0.031104647,-0.037120227,-0.0070385304,0.03623152,-0.06010997,-0.0040761833,-0.023788461,0.007862985,-0.0080264,0.0294231,-0.06763409,-0.027284352,0.02720548,0.012118604,-0.044641722,0.025212545,-0.0050499276,0.010612783,-0.0048592645,0.011480939,-0.038084067,0.050459232,-0.021653391,-0.016860519,-0.022343304,0.011578441,0.029444221,0.005036281,0.052007847,-0.00015478901,0.010757338,-0.008266853,-0.06438106,0.0038179888,0.010810128,0.014887702,0.041086577,0.09645378,-0.03697137,0.018508574,0.021505969,0.043335702,-0.015699612,0.006846198,0.059496786,0.042588223,-0.020572873,0.0042599905,0.0061774435,-0.0048375144,0.023622885,-0.013585687,0.012436588,-0.0031023244,-0.009659066,0.007284619,0.004994468,-0.03997744,0.025999822,-0.004322784,0.0021907475,0.027662035,0.013451684,0.0061220867,-0.053517137,0.011526667,-0.016668048,0.03389963,0.0024166985,-0.0087329345,0.0005349021,0.09292805,-0.024875147,0.01887172,-0.015814196,0.01548722,-0.0023607062,0.00095323403,0.040603366,-0.018233076,0.00050581084,-0.028851299,-0.05746257,-0.022262363,0.06411007,-0.015622854,0.02921695,0.032788914,-0.0042617223,-0.0026337528,0.029876161,0.026273958,-0.048925456,-0.014354729,0.0069950293,-0.046119038,-0.0015572214,-0.01648194,-0.018696893,-0.036625963,-0.0049027205,-0.002610518,-0.046027448,0.02675758,-0.033888668,0.0046032025,-0.017866787,-0.016139796]</code></pre>
<h6>Pourquoi une approche séquentielle ?</h6>
<p>Comme expliqué en introduction, sans GPU dédié, c'est mon CPU — plus précisément l'iGPU intégré à mon CPU — qui doit faire le travail de vectorisation.
Même si l'iGPU partage la RAM avec le CPU et bien que 32 Go soient disponibles <em>(modulo l'utilisation de mon système, de programme en cours, ...)</em>, la vitesse de la RAM n'a rien à voir avec la mémoire des cartes graphiques, bien plus rapide et dédiée.
De plus, le CPU ne peut traiter que quelques opérations en parallèle, là où le GPU en exécute des milliers simultanément ce qui fait que les calculs matriciels deviennent très lents et mobilisent ma machine à 100%.</p>
<p>Résultat, une seul vectorisation n'est possible à la fois.
Et donc, oui, un batch upsert serait plus rapide, mais le but était de construire un pipeline fonctionnel avec uniquement des ressources locales.</p>
<p>Ce n'est pas une limite de PHP ou de Qdrant, seulement un compromis pragmatique lié au matériel disponible.</p>
<h6>Atomicité par renommage de fichiers</h6>
<p>C'est la décision de design la plus intéressante. Au lieu d'une table en base de données pour suivre les fichiers traités, le <code translate="no">Builder</code> utilise des <code translate="no">rename()</code> atomiques :</p>
<pre><code translate="no">pull-{id}.txt               → prêt à traiter
processing_pull-{id}.txt    → en cours de vectorisation
ragged_pull-{id}.txt        → vectorisé avec succès</code></pre>
<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">build</span><span class="hljs-params">()</span>: <span class="hljs-title">void</span>
</span>{
    <span class="hljs-keyword">$this</span>-&gt;store-&gt;setup(); <span class="hljs-comment">// ManagedStoreInterface</span>

    <span class="hljs-keyword">$this</span>-&gt;recoverOrphanedProcessingFiles();

    <span class="hljs-keyword">foreach</span> (scandir(<span class="hljs-keyword">$this</span>-&gt;datasetDirectory) <span class="hljs-keyword">as</span> $file) {
        <span class="hljs-keyword">if</span> (<span class="hljs-string">'.'</span> === $file || <span class="hljs-string">'..'</span> === $file
            || str_starts_with($file, <span class="hljs-keyword">self</span>::RAGGED_PREFIX)
            || str_starts_with($file, <span class="hljs-keyword">self</span>::PROCESSING_PREFIX)) {
            <span class="hljs-keyword">continue</span>;
        }
        <span class="hljs-keyword">if</span> (!rename(<span class="hljs-keyword">$this</span>-&gt;datasetDirectory.<span class="hljs-string">'/'</span>.$file, <span class="hljs-keyword">$this</span>-&gt;datasetDirectory.<span class="hljs-string">'/'</span>.(<span class="hljs-string">'processing_'</span>.$file))) {
            <span class="hljs-keyword">continue</span>; <span class="hljs-comment">// un autre processus l'a pris</span>
        }

        <span class="hljs-keyword">try</span> {
            $content = file_get_contents(<span class="hljs-keyword">$this</span>-&gt;datasetDirectory.<span class="hljs-string">'/processing_'</span>.$file);
            $vector = <span class="hljs-keyword">$this</span>-&gt;vectorizer-&gt;vectorize($content);
            <span class="hljs-keyword">if</span> (<span class="hljs-number">768</span> !== \count($vector-&gt;getData())) {
                <span class="hljs-keyword">throw</span> <span class="hljs-keyword">new</span> \RuntimeException(<span class="hljs-string">'Wrong dimensions'</span>);
            }

            <span class="hljs-keyword">$this</span>-&gt;store-&gt;add(<span class="hljs-keyword">new</span> VectorDocument(
                id: (int) preg_replace(<span class="hljs-string">'/[^0-9]/'</span>, <span class="hljs-string">''</span>, $file),
                vector: $vector,
                metadata: <span class="hljs-keyword">new</span> Metadata([<span class="hljs-string">'content'</span> =&gt; $content]),
            ));

            rename(<span class="hljs-keyword">$this</span>-&gt;datasetDirectory.<span class="hljs-string">'/processing_'</span>.$file, <span class="hljs-keyword">$this</span>-&gt;datasetDirectory.<span class="hljs-string">'/ragged_'</span>.$file);
        } <span class="hljs-keyword">catch</span> (\Throwable $e) {
            rename(<span class="hljs-keyword">$this</span>-&gt;datasetDirectory.<span class="hljs-string">'/processing_'</span>.$file, <span class="hljs-keyword">$this</span>-&gt;datasetDirectory.<span class="hljs-string">'/'</span>.$file); <span class="hljs-comment">// rollback</span>
        }
    }
}</code></pre>
<p>Crash-safe par conception : si le script meurt en plein milieu, <code translate="no">recoverOrphanedProcessingFiles()</code> remet en file les orphelins <code translate="no">processing_*</code> au prochain lancement. Pas de locks, pas de base de données, pas de race conditions.</p>
<p>Oui, le code montre que, malgrés ce que j'ai dis plus haut :</p>
<blockquote>
<p>Résultat, une seul vectorisation n'est possible à la fois.</p>
</blockquote>
<p>Oui, j'ai quand même essayé 🤣.</p>
<h4>Utilisation</h4>
<p>La CLI expose deux commandes :</p>
<pre><code class="language-bash hljs bash" translate="no"><span class="hljs-comment"># Pipeline complet : récupération → dataset → vectorisation</span>
php bin/console mcp:build

<span class="hljs-comment"># Re-vectoriser sans re-récupérer</span>
php bin/console mcp:build --skip-generation

<span class="hljs-comment"># Re-récupérer sans re-vectoriser</span>
php bin/console mcp:build --skip-build</code></pre>
<h3 id="le-serveur-mcp">Le serveur MCP</h3>
<p>Pour que le LLM puisse avoir accès a ces review fraichement indéxés et effectuer ses recherches lui même, il faut lui donner les accès.
Tout ça s'effectue via le protocole MCP suivant ce pipeline d'appel simplifié :</p>
<pre class="mermaid d-flex flex-column m-2 justify-content-center align-items-center">
flowchart TD
    H["Appel Tool MCP (review_as_group/person)"] --&gt; I["Retriever (recherche sémantique)"]
    I --&gt; G["Qdrant (collection: reviews)"]
    I --&gt; J["Client LLM (Claude Desktop)"]
</pre>
<p>Le pipeline de génération (récupération → dataset → vectorisation) et le pipeline de service (recherche → réponse) partagent un seul point commun : la collection <a href="https://qdrant.tech/" rel="noopener noreferrer">Qdrant</a>. </p>
<p>Le serveur MCP expose deux <strong>tools</strong> et quatre <strong>prompts</strong> :</p>
<table>
<thead>
<tr>
<th>Tool</th>
<th>Rôle</th>
</tr>
</thead>
<tbody>
<tr>
<td><code translate="no">review_as_group</code></td>
<td>Recherche par groupe d'affiliation (MEMBER, CONTRIBUTOR, NONE)</td>
</tr>
<tr>
<td><code translate="no">review_as_person</code></td>
<td>Recherche par reviewer spécifique (nicolas-grekas, stof, etc.)</td>
</tr>
</tbody>
</table>
<table>
<thead>
<tr>
<th>Prompt</th>
<th>Rôle</th>
</tr>
</thead>
<tbody>
<tr>
<td><code translate="no">review_as_group</code></td>
<td>Message formaté utilisant <code translate="no">review_as_group</code></td>
</tr>
<tr>
<td><code translate="no">review_as_person</code></td>
<td>Message formaté utilisant <code translate="no">review_as_person</code></td>
</tr>
<tr>
<td><code translate="no">get_stofed</code></td>
<td>Force le reviewer à « stof » — le plus prolifique reviewer du Core Symfony</td>
</tr>
<tr>
<td><code translate="no">hq_review</code></td>
<td>Multi-review : interroge 14 reviewers et synthétise un rapport markdown</td>
</tr>
</tbody>
</table>
<h4>Recherche : les Tools MCP</h4>
<p>Quand un utilisateur envoie une requête via un tool MCP, voici ce qui se passe :</p>
<ol>
<li>Le tool construit une requête combinant reviewer, chemin de fichier et diff</li>
<li><code translate="no">RetrieverInterface::retrieve()</code> vectorise la requête via Ollama</li>
<li>Une recherche par similarité cosinus s'exécute sur Qdrant</li>
<li>Les <code translate="no">VectorDocument</code> correspondants sont retournés</li>
<li>Leur <code translate="no">metadata['content']</code> est extrait et assemblé en contexte</li>
</ol>
<pre class="mermaid d-flex flex-column m-2 justify-content-center align-items-center">
sequenceDiagram
    participant Client as Claude Desktop
    participant MCP as Serveur MCP (stdio)
    participant Tool as review_as_person
    participant Retriever
    participant Ollama
    participant Qdrant

    Client-&gt;&gt;MCP: call_tool(review_as_person)
    MCP-&gt;&gt;Tool: __invoke(pseudonym, file, diff, limit)
    Tool-&gt;&gt;Retriever: retrieve(query, ['limit' =&gt; limit])
    Retriever-&gt;&gt;Ollama: vectorize(query)
    Ollama--&gt;&gt;Retriever: vecteur de la requête
    Retriever-&gt;&gt;Qdrant: recherche par similarité cosinus
    Qdrant--&gt;&gt;Retriever: documents les plus proches
    Retriever--&gt;&gt;Tool: VectorDocument[]
    Tool--&gt;&gt;MCP: reviews trouvées (texte)
    MCP--&gt;&gt;Client: résultat du tool
</pre>
<pre><code class="language-php hljs php" translate="no"><span class="hljs-comment">#[McpTool(</span>
    name: <span class="hljs-keyword">self</span>::NAME,
    description: <span class="hljs-string">'Tool retrieving a `limit` amount of reviews from `pseudonym` github user based on a given git a complete `file` path and `diff`. Results are separated by `\n\n---\n\n`.'</span>,
    annotations: <span class="hljs-keyword">new</span> ToolAnnotations(<span class="hljs-string">'Review matching file diff as github user'</span>, <span class="hljs-keyword">true</span>, <span class="hljs-keyword">false</span>, <span class="hljs-keyword">true</span>, <span class="hljs-keyword">false</span>)
)]
<span class="hljs-keyword">final</span> readonly <span class="hljs-class"><span class="hljs-keyword">class</span> <span class="hljs-title">ReviewAsPersonMatchingFileDiffTool</span>
</span>{
    <span class="hljs-keyword">public</span> <span class="hljs-keyword">const</span> string NAME = <span class="hljs-string">'review_as_person'</span>;

    <span class="hljs-keyword">public</span> <span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">__invoke</span><span class="hljs-params">(string $pseudonym, string $file, string $diff, int $limit)</span>: <span class="hljs-title">string</span>
    </span>{
        $query = <span class="hljs-string">&lt;&lt;&lt;TXT
        reviewer: <span class="hljs-subst">$pseudonym</span>
        file: <span class="hljs-subst">$file</span>
        diff:
        <span class="hljs-subst">{$diff}</span>
        TXT;

        try {
            <span class="hljs-subst">$retrieved</span> = <span class="hljs-subst">$this</span>-&gt;retriever-&gt;retrieve(<span class="hljs-subst">$query</span>, ['limit' =&gt; <span class="hljs-subst">$limit</span>]);

            <span class="hljs-subst">$return</span> = [];
            foreach (<span class="hljs-subst">$retrieved</span> as <span class="hljs-subst">$document</span>) {
                <span class="hljs-subst">$content</span> = <span class="hljs-subst">$document</span>-&gt;getMetadata()['content'] ?? null;
                if (null === <span class="hljs-subst">$content</span> || !\is_string(<span class="hljs-subst">$content</span>)) {
                    continue;
                }
                <span class="hljs-subst">$return</span>[] = <span class="hljs-subst">$content</span>;
            }
        } catch (\Throwable <span class="hljs-subst">$exception</span>) {
            return 'Error retrieving reviews: '.<span class="hljs-subst">$exception</span>-&gt;getMessage();
        }

        if (0 === \count(<span class="hljs-subst">$return</span>)) {
            return 'No reviews found.';
        }

        return implode("\n\n---\n\n", <span class="hljs-subst">$return</span>);
    }
}</span></code></pre>
<h4>Le Prompt HQ Review</h4>
<p>Le prompt <code translate="no">hq_review</code> est la fonctionnalité vedette. Il interroge 14 reviewers Symfony de premier plan (<a href="https://github.com/GromNaN" rel="noopener noreferrer">GromNaN</a>, <a href="https://github.com/dunglas" rel="noopener noreferrer">dunglas</a>, <a href="https://github.com/welcoMattic" rel="noopener noreferrer">welcoMattic</a>, <a href="https://github.com/nicolas-grekas" rel="noopener noreferrer">nicolas-grekas</a>, <a href="https://github.com/chalasr" rel="noopener noreferrer">chalasr</a>, <a href="https://github.com/stof" rel="noopener noreferrer">stof</a>, <a href="https://github.com/yceruto" rel="noopener noreferrer">yceruto</a>, <a href="https://github.com/mtarld" rel="noopener noreferrer">mtarld</a>, <a href="https://github.com/OskarStark" rel="noopener noreferrer">OskarStark</a>, <a href="https://github.com/xabbuh" rel="noopener noreferrer">xabbuh</a>, <a href="https://github.com/lyrixx" rel="noopener noreferrer">lyrixx</a>, <a href="https://github.com/kbond" rel="noopener noreferrer">kbond</a>, <a href="https://github.com/jderusse" rel="noopener noreferrer">jderusse</a>, <a href="https://github.com/alexandre-daubois" rel="noopener noreferrer">alexandre-daubois</a>), collecte leurs feedbacks historiques sur le même fichier/diff, et demande au LLM de synthétiser un rapport markdown avec des retours pondérés par reviewer.</p>
<p>Le résultat est une code review qui ressemble à un mini-symposium des mainteneurs du Core Symfony — sans nécessiter leur temps.</p>
<h4>Utilisation</h4>
<pre><code class="language-bash hljs bash" translate="no">docker build -t symfony-reviewer-mcp-cli /path/to/Dockerfile
docker run -i --rm --add-host=host.docker.internal:host-gateway -e QDRANT_DSN=http://host.docker.internal:6333 -e OLLAMA_DSN=http://host.docker.internal:11434 symfony-reviewer-mcp-cli</code></pre>
<p>Puis configurer Claude Desktop (ou tout client MCP) en ajoutant le serveur à <code translate="no">claude_desktop_config.json</code> :</p>
<pre><code class="language-json hljs json" translate="no">{
  <span class="hljs-attr">"mcpServers"</span>: {
    <span class="hljs-attr">"symfony-reviewer"</span>: {
      <span class="hljs-attr">"command"</span>: <span class="hljs-string">"docker"</span>,
      <span class="hljs-attr">"args"</span>: [
            <span class="hljs-string">"run"</span>, <span class="hljs-string">"-i"</span>, <span class="hljs-string">"--rm"</span>,
            <span class="hljs-string">"--add-host=host.docker.internal:host-gateway"</span>,
            <span class="hljs-string">"-e"</span>, <span class="hljs-string">"QDRANT_DSN=http://host.docker.internal:6333"</span>,
            <span class="hljs-string">"-e"</span>, <span class="hljs-string">"OLLAMA_DSN=http://host.docker.internal:11434"</span>,
            <span class="hljs-string">"symfony-reviewer-mcp-cli"</span>
        ]
    }
  }
}</code></pre>
<h2 id="les-variables-d-env">Les variables d'env</h2>
<p>Toute la configuration passe par les variables <code translate="no">.env</code> :</p>
<table>
<thead>
<tr>
<th>Variable</th>
<th>Rôle</th>
</tr>
</thead>
<tbody>
<tr>
<td><code translate="no">GITHUB_TOKEN</code></td>
<td><a href="https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens" rel="noopener noreferrer">Token d'accès personnel GitHub</a></td>
</tr>
<tr>
<td><code translate="no">QDRANT_DSN</code></td>
<td>URL du service Qdrant</td>
</tr>
<tr>
<td><code translate="no">OLLAMA_DSN</code></td>
<td>URL du service Ollama</td>
</tr>
<tr>
<td><code translate="no">BLACKLISTED_PATTERN</code></td>
<td>Tableau JSON de patterns URL à exclure du cache</td>
</tr>
<tr>
<td><code translate="no">APP_VERSION</code></td>
<td>Version affichée dans les métadonnées MCP</td>
</tr>
</tbody>
</table>
<h2 id="lecons-apprises">Leçons apprises</h2>
<p>Ce projet m'a permis de comprendre comment fonctionne le protocole MCP, ce qu'est un base de données vectoriel et comment l'utiliser. L'architecture présentée ici est relativement classique pour un pipeline RAG mais il a été réalisé avec Symfony et peu servir de socle de base pour d'autres expérimentations ou usages.</p>
<h3 id="ce-qui-a-bien-fonctionne">Ce qui a bien fonctionné</h3>
<ul>
<li><strong>Atomicité par renommage de fichiers</strong> : Ce pattern est élégant, crash-safe et ne nécessite aucune infrastructure. Chaque développeur PHP comprend <code translate="no">rename()</code>. Pas de locks Redis, pas de migrations de base de données.</li>
<li><strong>Pipeline incrémental</strong> : Relancer <code translate="no">mcp:build</code> avec des fichiers existants est un no-op. L'itération est rapide — on peut ajuster la vectorisation et ne traiter que les nouveaux fichiers.</li>
<li><strong>Fonctionnalités PHP 8.x</strong> : La promotion de constructeur, les propriétés readonly, l'opérateur pipe (<code translate="no">|&gt;</code>), et les commandes invokables rendent le code bien plus propre.</li>
<li><strong><a href="https://ollama.com/" rel="noopener noreferrer">Ollama</a> en local</strong> : embeddinggemma-300m tourne sur CPU sans problème. 768 dimensions, c'est assez modeste pour des requêtes rapides mais assez riche pour la recherche sémantique sur des code reviews.</li>
</ul>
<h3 id="ce-qui-doit-etre-ameliore">Ce qui doit être amélioré</h3>
<p>Si j'avais eu une machine plus efficace avec un GPU dédié ou une RAM unifiée (👋 les propriétaires de Mac), j'aurai peut-être pu changer ce qui suit :</p>
<ul>
<li><strong>Interaction Qdrant naïve</strong> : Les documents sont ajoutés un par un. Un batch upsert serait nettement plus rapide pour les gros volumes.</li>
<li><strong>Pas de mise à jour incrémentale du RAG</strong> : Le pipeline est add-only. Il n'y a pas de mécanisme "builtin" <em>(c'est possible via le dashboard de qdrant)</em> pour purger ou mettre à jour les vecteurs existants quand des commentaires de PR sont édités sur GitHub. Ce qui en soit est un vrai/faux problème puisqu'on retrouve rarement de nouveaux commentaire sur des Pull-Requests déjà mergées.</li>
<li><strong>Ajoutez vos propres conventions</strong> : J'ai ajouté les reviews de Symfony, mais vous pouvez vous aussi modifier et adapter le code pour qu'il s'appuie sur un corpus de données supplémentaire comme les revues de vos collaborateurs.</li>
</ul>
<h3 id="ce-que-je-ferais-differemment">Ce que je ferais différemment</h3>
<ol>
<li><strong>Vectorisation par lots</strong> : Grouper les documents et vectoriser en lots pour un meilleur débit</li>
<li><strong>Récupération asynchrone</strong> : La phase de récupération des données est séquentielle par PR. Des requêtes concurrentes réduiraient significativement le temps de construction initial</li>
<li><strong>Webhook GitHub</strong> : Au lieu de reconstruire périodiquement, écouter les événements de PR mergées et mettre à jour le dataset de manière incrémentale</li>
<li><strong>Évaluation des modèles d'embeddings</strong> : 768 dimensions fonctionne bien, mais je devrais comparer différents modèles : des modèles plus petits (comme <a href="https://huggingface.co/sentence-transformers/all-MiniLM-L6-v2" rel="noopener noreferrer">all-MiniLM-L6-v2</a>, 384 dimensions) pour améliorer les performances, ou des modèles beaucoup plus gros afin d'évaluer le gain potentiel en qualité de recherche.</li>
<li><strong>Contexte élargi : commentaires de PR et diff complet</strong> : le dataset actuel ne retient que les review comments attachés à un diff précis. Il ignore les commentaires généraux de la PR (issue comments, description) et surtout le diff complet de la PR — un reviewer ne juge jamais une ligne isolée, il la juge dans le contexte du changement entier. Injecter les deux donnerait au modèle bien plus de matière pour comprendre pourquoi une review a été formulée ainsi.</li>
<li><strong>Exploiter le champ <code translate="no">metadata</code> pour le JSON brut de GitHub</strong> : <a href="https://github.com/symfony/ai-store" rel="noopener noreferrer"><code translate="no">symfony/ai-store</code></a> attache à chaque <code translate="no">VectorDocument</code> un objet <code translate="no">Metadata</code> (<code translate="no">Symfony\AI\Store\Document\Metadata</code>) qui voyage jusqu'au store choisi. Chez Qdrant, ce <code translate="no">Metadata</code> correspond exactement à la notion de <em>payload</em> : un objet JSON arbitraire attaché à chaque point, indexable et filtrable nativement — par exemple filtrer par <code translate="no">reviewer_association</code>, par date, ou par nombre de réactions, sans re-parser le texte du dataset. Aujourd'hui, seul <code translate="no">content</code> (le texte assemblé du fichier dataset) y est stocké ; j'y aurais ajouté la réponse JSON brute de l'API GitHub (PR + review + reactions), pour garder une trace exploitable indépendante du format texte généré.</li>
<li><strong>Exploiter les réactions du JSON GitHub</strong>: Les réactions se prêteraient d'ailleurs à mieux qu'un simple filtre : elles pourraient pondérer le score de recherche lui-même, pas juste être renvoyées dans le <code translate="no">content</code>. Depuis la version 1.14, Qdrant propose une <a href="https://qdrant.tech/documentation/search/hybrid-queries/" rel="noopener noreferrer"><em>Formula Query</em></a> qui permet de composer un score final à partir du score de similarité initial et de champs du payload, dans une même formule de reclassement. Une review avec dix <code translate="no">+1</code> remonterait alors devant une review isolée à zéro réaction, à similarité vectorielle égale — une façon de faire remonter les avis que la communauté a elle-même validés, Une fonctionnalitée qui meriterai d'être explorée.</li>
</ol>
<h2 id="est-ce-que-ca-fonctionne">Est-ce que ça fonctionne ?</h2>
<p>La grande question : « est-ce que les reviews produites sont réellement meilleures ? ».</p>
<p>Répondre Oui serait en parti faux. En effet, aujourd'hui, mon évaluation reste essentiellement basée sur un sentiment générale plutôt que sur de vraies données tangibles et quantifiables.
Les réponses générées <strong>me</strong> paraissent beaucoup plus dans le ton d'une review qui aurait été effectuée sur le dépôt Github de Symfony, et donc plus proches des ses conventions, que les réponses obtenues avec un LLM sans contexte.
Enfin, comme un LLM et par nature "probabiliste", je ne crois pas qu'il soit pertinant de dire que sur la base de mes propres observations et mon sentiment personnel seul le fonctionnement d'un outil est établi.</p>
<p>Voici un exemple de revue effectuée sur ce projet pour vous donner un idée :</p>
<pre><code class="language-markdown hljs markdown" translate="no">---

<span class="hljs-section">### 1. fabpot</span>
<span class="hljs-strong">**Files:**</span> <span class="hljs-code">`src/HTTP/CachedHttpClient.php`</span>, <span class="hljs-code">`src/HTTP/CachedResponse.php`</span>

<span class="hljs-quote">&gt; Thanks for the PR! A few things before we can merge:</span>
<span class="hljs-quote">&gt;
&gt; * Why is the cache key based on the URL only? For `POST` requests the body matters, otherwise all `/api/embed` calls will return the same result. This is a real bug, not an optimization issue.</span>
<span class="hljs-quote">&gt; * Please add a test covering the "non-buffered" response case — `getContent()` then `toArray()` will fail on `EventSourceHttpClient` responses.</span>
<span class="hljs-quote">&gt; * Do we need a `CHANGELOG` entry for the `CachedHttpClient`? I think we can keep it internal.</span>
<span class="hljs-quote">&gt;
&gt; Otherwise the approach is clean. Once the cache key is fixed, we can squash the commits.</span>

---

<span class="hljs-section">### 2. nicolas-grekas</span>
<span class="hljs-strong">**Files:**</span> <span class="hljs-code">`src/HTTP/CachedResponse.php`</span>, <span class="hljs-code">`src/Kernel.php`</span>

<span class="hljs-quote">&gt; Reading the response body twice is going to blow up the moment the response is not buffered. `toArray()` internally calls `getContent()` — so snapshot the content once and `json_decode` it, don't call both.</span>
<span class="hljs-quote">&gt;
&gt; Also, the decorated chain is wrong: `service(LoggedHttpClient::class)` no longer resolves to the logger once you decorate it with `CachedHttpClient`. Qdrant and Ollama are being routed through the GitHub cache without intent. Decorate a dedicated alias, e.g. `cached.github.http_client`, and keep `HttpClientInterface` as the plain chain.</span>
<span class="hljs-quote">&gt;
&gt; One more: `getContent(false)` is being snapshotted eagerly in the constructor — that defeats lazy streaming for large GitHub responses. Buffer lazily.</span>

---

<span class="hljs-section">### 3. stof</span>
<span class="hljs-strong">**Files:**</span> <span class="hljs-code">`src/Kernel.php`</span>

<span class="hljs-quote">&gt; Service wiring nit: decoration replaces the decorated id, so `$httpClient =&gt; service(LoggedHttpClient::class)` gives you the `CachedHttpClient`, not the logging client. That's a scope leak — the Qdrant store is now coupled to a cache tuned for the GitHub API (see `BLACKLISTED_PATTERN`).</span>
<span class="hljs-quote">&gt;
&gt; I'd define a dedicated `logged.http_client` service for the store/platform and only decorate `cached.github.http_client` for the GitHub fetchers. Also check the `-&gt;decorate(..., priority: 1)` priorities — with equal priorities the order of application is by declaration order, which is fragile to read.</span>
<span class="hljs-quote">&gt;
&gt; And the `json:BLACKLISTED_PATTERN` env var — document its format in the README.</span>

---

<span class="hljs-section">### 4. weaverryan</span>
<span class="hljs-strong">**Files:**</span> <span class="hljs-code">`src/MCP/Tools/ReviewAsPersonMatchingFileDiffTool.php`</span>

<span class="hljs-quote">&gt; Hey! Love the ergonomics of this tool — the `review_as_person` name makes the intent super clear. Great job composing the query with the pseudonym, file, and diff.</span>
<span class="hljs-quote">&gt;
&gt; One DX thought: when no reviews are found we return `No reviews found.` — that's good. But maybe give the caller a hint that they can reduce `limit` or widen the diff? Small thing, ignore if you want.</span>
<span class="hljs-quote">&gt;
&gt; Also, the metadata `content` check with the warning log is nice defensive coding. Keep it up! 🎉</span>

---

<span class="hljs-section">### 5. derrabus</span>
<span class="hljs-strong">**Files:**</span> <span class="hljs-code">`src/HTTP/CachedHttpClient.php`</span>

<span class="hljs-quote">&gt; Two things:</span>
<span class="hljs-quote">&gt;
&gt; 1. `$key = md5($method.$url)` — please include the serialized options/body. Hash collisions here are silent correctness bugs, not just perf issues.</span>
<span class="hljs-quote">&gt; 2. The `preg_match` on a compiled regex built via pipe chains is clever but hard to read. Since the blacklist is a list of exact strings, why not use `in_array` or a simple `str_starts_with` on the pattern list? Keep it simple.</span>
<span class="hljs-quote">&gt;
&gt; Also, the code is `final readonly` — good. But it implements `ResetInterface`; make sure the decorated inner `reset()` is reachable in `withOptions()` clones (it is, since you forward to the scoped client — just double-check the cache/lifecycle after cloning).</span>

---

<span class="hljs-section">### 6. xabbuh</span>
<span class="hljs-strong">**Files:**</span> <span class="hljs-code">`src/HTTP/CachedResponse.php`</span>

<span class="hljs-quote">&gt; I have concerns about the snapshot in the constructor:</span>
<span class="hljs-quote">&gt;
&gt; * Calling `$response-&gt;getContent()` eagerly downloads and stores the whole payload. For the GitHub fetchers this is fine, but a general-purpose cache should stream lazily.</span>
<span class="hljs-quote">&gt; * More importantly, `getContent()` followed by `toArray()` breaks for responses that disabled buffering (the Ollama `EventSourceHttpClient` forces `buffer =&gt; false`). This is an exploitable/observable crash — at minimum it should throw a clear `TransportException` or read once.</span>
<span class="hljs-quote">&gt; * `getInfo()` filtering out closures is a nice touch, but the returned array is shallow — nested closures could still leak. Use a recursive filter or `json_encode/decode` the info array.</span>

---

<span class="hljs-section">### 7. Tobion</span>
<span class="hljs-strong">**Files:**</span> <span class="hljs-code">`src/HTTP/CachedHttpClient.php`</span>

<span class="hljs-quote">&gt; The pipeline operator chains in `request()` are over-engineered for building a regex. `implode('|', array_map(preg_quote(...), $this-&gt;blacklistedPatterns))` is enough. As written, an empty blacklist produces the pattern `#^$#` which would match an empty call string — harmless, but misleading.</span>
<span class="hljs-quote">&gt;
&gt; More importantly: cache invalidation. There is none — responses are cached for a year (`defaultLifetime`). GitHub data changes; the fetchers need a way to bust the cache (e.g. include a version/tag in the key or a TTL per-URL). Otherwise reviews fetched once are served stale forever.</span>

---

<span class="hljs-section">### 8. mpdude</span>
<span class="hljs-strong">**Files:**</span> <span class="hljs-code">`src/RAG/Builder.php`</span> (via <span class="hljs-code">`Store`</span>)

<span class="hljs-quote">&gt; The Qdrant indexing loop swallows exceptions and logs `Index failed` — but the build then *continues* (I saw `Indexing document continues`). If a document fails vectorization, subsequent documents are still sent. That means the collection is only partially populated, and `review_as_person` will silently return "No reviews found" or partial results.</span>
<span class="hljs-quote">&gt;
&gt; Please make the build fail-fast or at least surface a summary count at the end ("indexed X / failed Y") so operators know the dataset is incomplete. Right now nothing tells us that only ~1106 of 8691 documents made it in.</span>

---

<span class="hljs-section">### 9. WouterJ</span>
<span class="hljs-strong">**Files:**</span> <span class="hljs-code">`src/Kernel.php`</span>

<span class="hljs-quote">&gt; The container config reads really well — the decoration chain is easy to follow. Nice use of `env('json:BLACKLISTED_PATTERN')` and `StoreFactory::create(...)`.</span>
<span class="hljs-quote">&gt;
&gt; Minor: the `logged.http_client` vs `cached.github.http_client` distinction is muddied because both decorators use `priority: 1` and decorate each other's ids. I'd give them explicit service aliases (`github.logged.http_client`, etc.) so the intent is obvious. Also the unused `'stream_handler'` monolog handler and the commented-out `http` transport block could be cleaned up before merge.</span>

---

<span class="hljs-section">### 10. alexislefebvre</span>
<span class="hljs-strong">**Files:**</span> <span class="hljs-code">`tests/HTTP/CachedHttpClientTest.php`</span>

<span class="hljs-quote">&gt; Nice test coverage — you test blacklist skipping, persistence across instances, `withOptions` cloning, and `reset`. 👍</span>
<span class="hljs-quote">&gt;
&gt; Missing cases I'd love to see:</span>
<span class="hljs-quote">&gt;
&gt; 1. A `POST` request with a body — assert that different bodies don't collide in the cache (this would catch the `md5(method.url)` bug).</span>
<span class="hljs-quote">&gt; 2. A non-buffered/streaming response (`MockResponse` with `buffer =&gt; false` is hard to fake; but at least an SSE-like response) going through `CachedResponse` without throwing.</span>
<span class="hljs-quote">&gt; 3. The cache should not be hit for `POST`/`PUT` (or should include the body in the key) — please encode that expectation in a test.</span>

---

<span class="hljs-section">### 11. Nyholm</span>
<span class="hljs-strong">**Files:**</span> <span class="hljs-code">`src/HTTP/LoggedHttpClient.php`</span>, <span class="hljs-code">`src/HTTP/CachedHttpClient.php`</span>

<span class="hljs-quote">&gt; As the http-client component maintainer: don't re-implement caching. Symfony's `HttpClient` supports a `cache` option natively via the `http_cache` from the contracts, and it handles cache keys, headers, `Vary`, and ETags properly. Rolling your own `md5(method.url)` cache key is a regression waiting to happen (it already broke on POST bodies).</span>
<span class="hljs-quote">&gt;
&gt; If you keep the custom decorator, at least delegate to `CacheItemPoolInterface` semantics and include the request payload + relevant headers in the key. And please make `stream()` forward correctly — it does, but note that cached responses can never stream, which may surprise callers.</span>
<span class="hljs-quote">&gt;
&gt; Also: `FileSystemAdapter` on a single Docker container is fine, but for multi-instance deploys you'll want a shared pool (Redis). Worth a comment.</span>

---

<span class="hljs-section">### 12. jderusse</span>
<span class="hljs-strong">**Files:**</span> <span class="hljs-code">`src/RAG/Builder.php`</span>, <span class="hljs-code">`src/HTTP/CachedHttpClient.php`</span>

<span class="hljs-quote">&gt; The elephant in the room: the per-document vectorization loop is serial. 8691 documents, ~10s each — that's ~24h to build the RAG, and with the cache bug most embeddings were identical (same URL → same key). That's why retrieval feels broken.</span>
<span class="hljs-quote">&gt;
&gt; Fixes I'd push for:</span>
<span class="hljs-quote">&gt; * Parallelize vectorization with Symfony's `AsyncResponse` / `stream()` over batches.</span>
<span class="hljs-quote">&gt; * Include the body in the cache key (obviously).</span>
<span class="hljs-quote">&gt; * Index the docs that failed (`Index failed` ×636) with retry/backoff.</span>
<span class="hljs-quote">&gt;
&gt; Also `Builder` should checkpoint progress so a crash doesn't restart from zero.</span>

---

<span class="hljs-section">### 13. chalasr</span>
<span class="hljs-strong">**Files:**</span> <span class="hljs-code">`src/MCP/Tools/ReviewAsPersonMatchingFileDiffTool.php`</span>, <span class="hljs-code">`src/Command/ServeCommand.php`</span>

<span class="hljs-quote">&gt; Tool ergonomics are good — `limit` is explicit, errors are caught and surfaced. But: the error path returns `Error retrieving reviews: {message}` as a *successful* tool result. For an MCP server, real failures should be proper exceptions/tool errors, not strings, otherwise the client can't distinguish "no data" from "server broken".</span>
<span class="hljs-quote">&gt;
&gt; Also, the query string embeds the raw diff with no size guard — a huge diff will blow the embedding context window. Truncate or chunk the diff.</span>
<span class="hljs-quote">&gt;
&gt; And the serve command: make sure `APP_DEBUG` is off in prod and there's a graceful shutdown on SIGTERM.</span>

---

<span class="hljs-section">### 14. yceruto</span>
<span class="hljs-strong">**Files:**</span> <span class="hljs-code">`src/Kernel.php`</span>, <span class="hljs-code">`src/HTTP/CachedHttpClient.php`</span>

<span class="hljs-quote">&gt; The routing of the HTTP decorators deserves attention: `LoggedHttpClient::class` is decorated by `CachedHttpClient`, so every consumer referencing it — including the Qdrant store — ends up behind the GitHub cache. That coupling is accidental.</span>
<span class="hljs-quote">&gt;
&gt; I'd restructure like this:</span>
<span class="hljs-quote">&gt; ```</span>
<span class="hljs-quote">&gt; HttpClientInterface          # plain</span>
<span class="hljs-quote">&gt;  └─ logged.http_client       # logging only (for Qdrant/Ollama)</span>
<span class="hljs-quote">&gt;  └─ cached.github.http_client # cache + github token (for GitHub fetchers)</span>
<span class="hljs-quote">&gt; ```</span>
<span class="hljs-quote">&gt; Two separate chains, no cross-decorating. Then the cache key issue (URL-only, no body) also only affects GitHub GETs, which is safe.</span>
<span class="hljs-quote">&gt;
&gt; After that, the 14 `review_as_person` calls will stop returning the `buffering is disabled` error and start returning real reviews.</span>

---</code></pre>
<h2 id="pour-conclure">Pour conclure</h2>
<div style="width:100%;height:0;padding-bottom:56%;position:relative;">
    <iframe src="https://giphy.com/embed/NRiRXQTwbijNba2l2l" width="100%" height="100%" style="position:absolute" frameborder="0" class="giphy-embed" allowfullscreen></iframe>
</div>
<p><a href="https://giphy.com/gifs/The-Animal-Crackers-Movie-baking-try-it-NRiRXQTwbijNba2l2l">via GIPHY</a></p>
<p>Essayez-le vous même, le <a href="https://github.com/ktherage/symfony-review-mcp" rel="noopener noreferrer">projet</a> est conçu pour être autonome et indépendant. Les données sont publiquements accessibles, vous pouvez créé une instance Qdrant et Ollama facilement avec <a href="https://www.docker.com/" rel="noopener noreferrer">Docker</a>.</p>
<p>Pour l'installer :</p>
<pre><code class="language-bash hljs bash" translate="no">git <span class="hljs-built_in">clone</span> https://github.com/ktherage/symfony-review-mcp
<span class="hljs-built_in">cd</span> symfony-review-mcp
docker compose run --rm cli composer install
docker compose run --rm cli bin/console mcp:build
docker compose up -d</code></pre>
<p>La chose la plus surprenante que j'ai apprise en construisant ce projet : PHP est un langage parfaitement viable pour les pipelines RAG. Les composants HttpClient, Cache et Console de Symfony, combinés avec les packages <a href="https://github.com/symfony/ai" rel="noopener noreferrer"><code translate="no">symfony/ai-*</code></a>, gèrent tout, de la décoration HTTP aux opérations sur bases vectorielles. </p>
<p>Vous n'avez pas besoin de Python pour faire de la recherche sémantique.</p>
<p>Parfois, le meilleur outil pour le travail est celui que vous maîtrisez déjà.</p>]]>
    </content>
  </entry>
  <entry xml:lang="fr">
    <id>https://ktherage.github.io/fr/blog/2026/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/2026/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>
  <entry xml:lang="fr">
    <id>https://ktherage.github.io/fr/blog/2026/dealing-with-isolated-phpstan-1-and-the-phpunit-13-blindspot/</id>
    <title>Gérer l&#039;isolation de PHPStan 1 et l&#039;angle mort de PHPUnit 13</title>
    <published>2026-05-29T00:00:00+00:00</published>
    <link href="https://ktherage.github.io/fr/blog/2026/dealing-with-isolated-phpstan-1-and-the-phpunit-13-blindspot/" rel="alternate" type="text/html" />
    <content type="html">
      <![CDATA[<p>Nous adorons les outils de développement isolés. Placer PHPStan, Rector ou PHP CS Fixer dans des sous-répertoires distincts comme <code translate="no">.tools/phpstan/</code> avec leur propre <code translate="no">composer.json</code> est un excellent moyen d'éviter l'enfer des dépendances dans votre projet racine.</p>
<p>Jusqu'à ce que cela rende votre pipeline d'analyse complètement aveugle.</p>
<p>Si vous êtes récemment passé à <strong>PHPUnit 13</strong> et que votre analyse statique a soudainement déraillé avec des erreurs fantômes du type <code translate="no">unknown class PHPUnit\Framework\TestCase</code>, vous vous êtes heurté à un mur d'isolation classique. Voyons pourquoi cela ne fonctionne plus et comment corriger le tir proprement.</p>
<hr>
<h2 id="le-symptome">Le Symptôme</h2>
<p>Votre suite de tests s'exécute dans Docker. Tout passe haut la main. Chaque assertion est au vert.
Pourtant, dès que vous lancez PHPStan, votre terminal explose :</p>
<pre><code class="language-text" translate="no"> ------ ---------------------------------------------------------------------------- 
  Line   tests/Client/FakeClientTest.php                                             
 ------ ---------------------------------------------------------------------------- 
  12     Class App\Tests\Client\FakeClientTest extends unknown class                 
         PHPUnit\Framework\TestCase.                                                 
         💡 Learn more at https://phpstan.org/user-guide/discovering-symbols         
  30     Call to an undefined static method                                          
         App\Tests\Client\FakeClientTest::assertInstanceOf().                        
 ------ ---------------------------------------------------------------------------- </code></pre>
<p>Vous jetez un œil à votre fichier <code translate="no">phpstan.neon.dist</code>. Vous avez pourtant déjà fait le pont en indiquant à PHPStan où trouver l'autoloader du projet :</p>
<pre><code class="language-yaml hljs yaml" translate="no"><span class="hljs-attr">parameters:</span>
    <span class="hljs-attr">level:</span> <span class="hljs-string">max</span>
    <span class="hljs-attr">paths:</span>
        <span class="hljs-bullet">-</span> <span class="hljs-string">src/</span>
        <span class="hljs-bullet">-</span> <span class="hljs-string">tests/</span>
    <span class="hljs-attr">bootstrapFiles:</span>
        <span class="hljs-bullet">-</span> <span class="hljs-string">vendor/autoload.php</span></code></pre>
<p>Vous double-vérifiez même l'autoloader manuellement via PHP :</p>
<pre><code class="language-bash hljs bash" translate="no">php -r <span class="hljs-string">"require 'vendor/autoload.php'; echo class_exists('PHPUnit\Framework\TestCase') ? '🟢 OUI' : '🔴 NON';"</span></code></pre>
<p>La console renvoie <code translate="no">🟢 OUI</code>. La classe est bien là. Alors, pourquoi PHPStan est-il aveugle ?</p>
<hr>
<h2 id="pourquoi-cela-fonctionnait-il-tres-bien-avec-phpunit-10">Pourquoi cela fonctionnait-il très bien avec PHPUnit 10 ?</h2>
<p>Si vous avez exactement cette même configuration sur un projet plus ancien tournant sous PHPUnit 9.5, tout fonctionne sans accroc. Qu'est-ce qui a changé ?</p>
<h3 id="1-le-virage-architectural-de-phpunit-10">1. Le virage architectural de PHPUnit 10+</h3>
<p>Dans PHPUnit 9, <code translate="no">TestCase</code> était une classe plutôt monolithique. Le moteur de réflexion statique de PHPStan (<code translate="no">BetterReflection</code>) n'avait aucun mal à la cartographier depuis un répertoire externe.</p>
<p>Avec PHPUnit 10 (et jusqu'à la v13), le framework a été entièrement refactorisé. <code translate="no">TestCase</code> s'appuie désormais sur un réseau complexe d'interfaces et de traits internes. Lorsque PHPStan tente de l'inspecter à distance au-delà des frontières du répertoire via un autoloader bootstrappé, le moteur de réflexion se perd dans l'arbre d'héritage et considère prudemment que la classe n'existe pas.</p>
<h3 id="2-le-piege-de-l-extension-obsolete">2. Le piège de l'extension obsolète</h3>
<p>Si vous regardez le fichier <code translate="no">.tools/phpstan/composer.json</code> de votre outil isolé, vous y trouverez probablement une contrainte héritée d'un ancien boilerplate de projet :</p>
<pre><code class="language-json hljs json" translate="no"><span class="hljs-string">"require"</span>: {
    <span class="hljs-attr">"phpstan/phpstan"</span>: <span class="hljs-string">"*"</span>,
    <span class="hljs-attr">"phpstan/phpstan-phpunit"</span>: <span class="hljs-string">"^1.1"</span>
}</code></pre>
<p>Cette contrainte <code translate="no">^1.1</code> verrouille l'extension PHPUnit sur sa <strong>branche 1.x</strong>, historiquement conçue pour PHPUnit 9. L'extension étant bloquée en v1.x, Composer fige silencieusement le cœur de <code translate="no">phpstan/phpstan</code> dans une version obsolète elle aussi (comme la <code translate="no">1.12.x</code>), ignorant complètement votre wildcard <code translate="no">*</code>. Vous vous retrouvez concrètement à analyser du code moderne sous PHPUnit 13 avec un moteur daté.</p>
<hr>
<h2 id="la-solution-propre-abandonner-les-contraintes-obsoletes">La solution propre : abandonner les contraintes obsolètes</h2>
<p>Plutôt que de vous battre avec les chemins via <code translate="no">scanDirectories</code> ou d'installer une copie factice de PHPUnit dans le répertoire de vos outils, mettez simplement à jour votre chaîne d'outils. <strong>PHPStan 2.0</strong> et son extension <strong>phpstan-phpunit 2.0</strong> gèrent nativement l'architecture complexe du PHPUnit moderne.</p>
<h3 id="1-passer-a-la-v2">1. Passer à la v2</h3>
<p>Ouvrez <code translate="no">.tools/phpstan/composer.json</code> et forcez la mise à jour :</p>
<pre><code class="language-json hljs json" translate="no">{
    <span class="hljs-attr">"require"</span>: {
        <span class="hljs-attr">"php"</span>: <span class="hljs-string">"&gt;=8.4"</span>,
        <span class="hljs-attr">"phpstan/phpstan"</span>: <span class="hljs-string">"^2.0"</span>,
        <span class="hljs-attr">"phpstan/phpstan-phpunit"</span>: <span class="hljs-string">"^2.0"</span>
    },
    <span class="hljs-attr">"config"</span>: {
        <span class="hljs-attr">"bin-dir"</span>: <span class="hljs-string">"./"</span>,
        <span class="hljs-attr">"sort-packages"</span>: <span class="hljs-literal">true</span>
    }
}</code></pre>
<h3 id="2-rafraichir-l-environnement">2. Rafraîchir l'environnement</h3>
<p>Lancez une mise à jour dans le répertoire de votre outil pour reconstruire le fichier lock :</p>
<pre><code class="language-bash hljs bash" translate="no"><span class="hljs-built_in">cd</span> .tools/phpstan &amp;&amp; composer update</code></pre>
<h3 id="3-vider-le-cache-et-analyser">3. Vider le cache et analyser</h3>
<p>Assurez-vous que votre fichier <code translate="no">phpstan.neon.dist</code> utilise bien la variable de chemin absolu pour cibler le répertoire vendor racine de manière sécurisée :</p>
<pre><code class="language-yaml hljs yaml" translate="no"><span class="hljs-attr">parameters:</span>
    <span class="hljs-attr">level:</span> <span class="hljs-string">max</span>
    <span class="hljs-attr">paths:</span>
        <span class="hljs-bullet">-</span> <span class="hljs-string">src/</span>
        <span class="hljs-bullet">-</span> <span class="hljs-string">tests/</span>
    <span class="hljs-attr">bootstrapFiles:</span>
        <span class="hljs-bullet">-</span> <span class="hljs-string">%currentWorkingDirectory%/vendor/autoload.php</span>

<span class="hljs-attr">includes:</span>
    <span class="hljs-bullet">-</span> <span class="hljs-string">.tools/phpstan/vendor/phpstan/phpstan-phpunit/extension.neon</span>
    <span class="hljs-bullet">-</span> <span class="hljs-string">.tools/phpstan/vendor/phpstan/phpstan-phpunit/rules.neon</span></code></pre>
<p>Supprimez l'ancien cache d'analyse pour éviter les résultats obsolètes :</p>
<pre><code class="language-bash hljs bash" translate="no">.tools/phpstan/phpstan clear-result-cache</code></pre>
<p>Relancez votre analyseur. Les erreurs fantômes vont disparaître et vous retrouverez vos indicateurs au vert, sans pour autant dégrader l'architecture isolée de vos outils.</p>]]>
    </content>
  </entry>
  <entry xml:lang="fr">
    <id>https://ktherage.github.io/fr/blog/2026/ubuntu-25-10-docker-java-cgroupv2-crash/</id>
    <title>Ubuntu 25.10 : La mise à jour qui a brické mes conteneurs Docker Java</title>
    <published>2026-04-28T00:00:00+00:00</published>
    <updated>2026-06-18T00:00:00+00:00</updated>
    <link href="https://ktherage.github.io/fr/blog/2026/ubuntu-25-10-docker-java-cgroupv2-crash/" rel="alternate" type="text/html" />
    <content type="html">
      <![CDATA[<h2 id="la-mise-a-jour-tranquille-qui-tourne-au-cauchemar">La mise à jour tranquille qui tourne au cauchemar</h2>
<p>C'était un vendredi soir, comme les autres. Je décide enfin de faire ce que tout bon développeur évite : <strong>mettre à jour son OS</strong>. Ubuntu 24.04 LTS → 25.10, la petite mise à jour de routine. <em>"Ça ne peut que s'améliorer"</em>, me dis-je avec ce pessimisme propre à ceux qui ont vu trop de mises à jour mal se passer (Ubuntu 24.10 je te vois !).</p>
<p>Je lance la mise à jour en confiance. Tout se passe bien. Redémarrage. Tout fonctionne. Nickel.</p>
<p>Sauf que le lundi suivant, mes tests E2E ne passent plus. Le conteneur Docker <code translate="no">selenium/standalone-chrome:4.5.3</code> qui fonctionnait parfaitement la veille refuse désormais de démarrer. Il plante en boucle avec ce magnifique message d'erreur :</p>
<pre><code translate="no">chrome-1  | java.lang.reflect.InvocationTargetException
chrome-1  |     at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
chrome-1  |     at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62)
chrome-1  |     at java.base/jdk.internal.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)
chrome-1  |     at java.base/java.lang.reflect.Method.invoke(Method.java:566)
chrome-1  |     at org.openqa.selenium.grid.Bootstrap.runMain(Bootstrap.java:77)
chrome-1  |     at org.openqa.selenium.grid.Bootstrap.main(Bootstrap.java:70)
chrome-1  | Caused by: java.lang.NullPointerException
chrome-1  |     at java.base/jdk.internal.platform.cgroupv2.CgroupV2Subsystem.getInstance(CgroupV2Subsystem.java:81)
chrome-1  |     at java.base/jdk.internal.platform.CgroupSubsystemFactory.create(CgroupSubsystemFactory.java:113)
chrome-1  |     at java.base/jdk.internal.platform.CgroupMetrics.getInstance(CgroupMetrics.java:167)
chrome-1  |     at java.base/jdk.internal.platform.SystemMetrics.instance(SystemMetrics.java:29)
chrome-1  |     at java.base/jdk.internal.platform.Metrics.systemMetrics(Metrics.java:58)
chrome-1  |     at java.base/jdk.internal.platform.Container.metrics(Container.java:43)
chrome-1  |     at jdk.management/com.sun.management.internal.OperatingSystemImpl.&lt;init&gt;(OperatingSystemImpl.java:182)
chrome-1  |     at jdk.management/com.sun.management.internal.PlatformMBeanProviderImpl.getOperatingSystemMXBean(PlatformMBeanProviderImpl.java:281)
chrome-1  |     at jdk.management/com.sun.management.internal.PlatformMBeanProviderImpl$3.nameToMBeanMap(PlatformMBeanProviderImpl.java:198)
chrome-1  |     at java.management/java.lang.management.ManagementFactory.lambda$getPlatformMBeanServer$0(ManagementFactory.java:487)
chrome-1  |     at java.base/java.util.stream.ReferencePipeline$7$1.accept(ReferencePipeline.java:271)
chrome-1  |     at java.base/java.util.stream.ReferencePipeline$2$1.accept(ReferencePipeline.java:177)
chrome-1  |     at java.base/java.util.HashMap$ValueSpliterator.forEachRemaining(HashMap.java:1693)
chrome-1  |     at java.base/java.util.stream.AbstractPipeline.copyInto(AbstractPipeline.java:484)
chrome-1  |     at java.base/java.util.stream.AbstractPipeline.wrapAndCopyInto(AbstractPipeline.java:474)
chrome-1  |     at java.base/java.util.stream.ForEachOps$ForEachOp.evaluateSequential(ForEachOps.java:150)
chrome-1  |     at java.base/java.util.stream.ForEachOps$ForEachOp$OfRef.evaluateSequential(ForEachOps.java:173)
chrome-1  |     at java.base/java.util.stream.AbstractPipeline.evaluate(AbstractPipeline.java:234)
chrome-1  |     at java.base/java.util.stream.ReferencePipeline.forEach(ReferencePipeline.java:497)
chrome-1  |     at java.management/java.lang.management.ManagementFactory.getPlatformMBeanServer(ManagementFactory.java:488)
chrome-1  |     at org.openqa.selenium.grid.jmx.JMXHelper.register(JMXHelper.java:29)
chrome-1  |     at org.openqa.selenium.grid.server.BaseServerOptions.&lt;init&gt;(BaseServerOptions.java:48)
chrome-1  |     at org.openqa.selenium.grid.commands.Standalone.createHandlers(Standalone.java:124)
chrome-1  |     at org.openqa.selenium.grid.TemplateGridServerCommand.asServer(TemplateGridServerCommand.java:41)
chrome-1  |     at org.openqa.selenium.grid.commands.Standalone.execute(Standalone.java:245)
chrome-1  |     at org.openqa.selenium.grid.TemplateGridCommand.lambda$configure$4(TemplateGridCommand.java:129)
chrome-1  |     at org.openqa.selenium.grid.Main.launch(Main.java:83)
chrome-1  |     at org.openqa.selenium.grid.Main.go(Main.java:57)
chrome-1  |     at org.openqa.selenium.grid.Main.main(Main.java:42)
chrome-1  |     ... 6 more</code></pre>
<p><strong>Ma première pensée :</strong></p>
<div class="d-flex flex-row m-2 justify-content-center">
  <iframe src="https://giphy.com/embed/4ZxicT7ZQYcLShHOiz" width="480" height="274" style="" frameborder="0" class="giphy-embed" allowfullscreen></iframe>
</div>
<blockquote>
<p>Je suis développeur PHP, pas Java.</p>
</blockquote>
<p><strong>Mon reflexe:</strong></p>
<div class="d-flex flex-row m-2 justify-content-center">
  <iframe src="https://giphy.com/embed/pUVOeIagS1rrqsYQJe" width="480" height="288" style="" frameborder="0" class="giphy-embed" allowfullscreen></iframe>
</div>
<blockquote>
<p>Demandons à plus fort que soi. Gemini cricket (🤖🦗) 🥲.</p>
</blockquote>
<h2 id="l-enquete">L'Enquête</h2>
<p>Mon ami le cricket met le doigt sur <strong>cgroupv2</strong> dans la ligne <code translate="no">CgroupV2Subsystem.java:81</code> et fait le lien avec la mise à jour de mon système. Mais <strong>cgroupv2</strong>, c'est quoi ?</p>
<p>🤖🦗:</p>
<blockquote>
<p>Pour faire court, c'est ce qui permet à Docker 🐋 de limiter le CPU ou la RAM d'un container.</p>
</blockquote>
<p>Concrètement :</p>
<ul>
<li>Docker dit à la JVM : <em>"Tu as le droit à 2Go de RAM"</em>.</li>
<li>Java lit ces infos dans les <strong>cgroups</strong> (fichiers de gestion des ressources).</li>
<li>Java ajuste son comportement (mémoire Heap, etc.) en conséquence.</li>
</ul>
<p><strong>C'est censé être une bonne chose.</strong> Cela évite que Java ne se fasse abattre par le <em>OOM Killer</em> du système hôte. Mais Java doit parser ces fichiers, et c'est là que le bât blesse.</p>
<blockquote>
<p><strong>Pourquoi un crash et pas juste une erreur ?</strong>
Dans le code source des anciennes JVM, si le chemin retourné par l'interface système n'est pas exactement celui attendu, la variable <code translate="no">mountPoint</code> reste à <code translate="no">null</code>. La JVM tente ensuite d'appeler une méthode sur cet objet inexistant. C'est l'arroseur arrosé : la fonction censée protéger votre application devient la cause de son exécution sommaire.</p>
</blockquote>
<h2 id="le-plot-twist-la-difference-subtile-entre-ubuntu-24-04-et-25-10">Le Plot Twist : La différence subtile entre Ubuntu 24.04 et 25.10</h2>
<p>C'est ici que l'affaire devient fascinante.</p>
<p>Le véritable coupable, c'est l'évolution de systemd (passé en version 258 sur la version d'Ubuntu 25.10). Depuis la v256, systemd impose un 'durcissement' de la hiérarchie cgroup v2. Il ne se contente plus d'exposer les contrôleurs ; il les organise de façon beaucoup plus granulaire pour isoler les services. Les anciennes versions de Java, conçues à une époque où la hiérarchie était plus prévisible et moins protégée, se retrouvent littéralement 'aveugles' face à cette nouvelle structure.</p>
<p><strong>Et devinez quoi ?</strong> La vieille logique d'initialisation de Java (avant Java 17) est beaucoup trop rigide pour comprendre ce nouveau format.</p>
<p>Au démarrage, le Java de notre conteneur fouille dans le système, ne trouve pas le contrôleur "memory" exactement là où il l'attendait, et assigne silencieusement null à sa variable interne. À la ligne suivante, le code tente d'appeler la méthode .getMountPoint() sur cet objet vide.</p>
<p><strong>BOOM</strong>. NullPointerException. Mort instantanée du processus.</p>
<p>Le coupable n'était ni notre code, ni notre configuration Docker, mais une ancienne JVM incapable de s'adapter à la nouvelle hiérarchie d'un noyau Linux moderne. Normale aussi vous me direz.</p>
<h2 id="la-solution">LA Solution</h2>
<p>La solution propre, celle qu'on devrait toujours utiliser en production :</p>
<pre><code class="language-bash hljs bash" translate="no"><span class="hljs-comment"># Mettre à jour vers une version récente de l'image</span>
docker pull selenium/standalone-chrome:4.20.0

<span class="hljs-comment"># OU utiliser une image avec Java 17+</span>
docker pull selenium/standalone-chrome:latest</code></pre>
<h2 id="le-hack-de-survie-desactiver-usecontainersupport">Le Hack de survie — Désactiver UseContainerSupport</h2>
<p>Si vous ne pouvez pas mettre à jour l'image (contraintes legacy, validation QA, etc.), vous pouvez désactiver la détection de conteneur :</p>
<pre><code class="language-bash hljs bash" translate="no"><span class="hljs-comment"># Option 1 : Via variable d'environnement Docker</span>
docker run -d \
  -e JAVA_OPTS=<span class="hljs-string">"-XX:-UseContainerSupport"</span> \
  selenium/standalone-chrome:4.5.3

<span class="hljs-comment"># Option 2 : Via docker-compose.yml</span>
services:
  chrome:
    image: selenium/standalone-chrome:4.5.3
    environment:
      - JAVA_OPTS=-XX:-UseContainerSupport</code></pre>
<aside class="note note-warning"><p><strong>Attention:</strong> Si vous cummulez plusieurs options, pensez a les séparer par des espaces. Exemple : <code translate="no">JAVA_OPTS="SOME_EXISTING_OPTIONS -XX:-UseContainerSupport</code></p></aside>
<aside class="note note-caution"><p><strong>⚠️ Avertissement important :</strong></p>
<ul>
<li><strong>NE FAITES PAS ÇA EN PRODUCTION.</strong>, dans mon cas il s'agit d'un container de <strong>developpement</strong> en locale.</li>
<li>Sans <code translate="no">UseContainerSupport</code>, Java ne connaît pas ses limites et peut se faire killer par le OOM Killer du système hôte.</li>
<li>Cette solution est un <strong>pansement temporaire</strong> en attendant la mise à jour.</li>
</ul></aside>
<h2 id="la-morale-de-cette-histoire">La morale de cette histoire</h2>
<p>Nos écosystèmes logiciels sont <strong>fragiles</strong>. Une simple mise à jour du noyau Linux — via une mise à jour d'Ubuntu dans mon cas — peut casser des conteneurs qui fonctionnaient parfaitement d'une version à l'autre.</p>
<p><strong>Les conseils philosophiques :</strong></p>
<blockquote>
<p><em>Penser à nettoyer sa chambre régulièrement (je suis sûr que vous comprenez l'image) peut faire gagner beaucoup de temps.</em></p>
</blockquote>
<blockquote>
<p><em>Ne faites jamais de mise à jour d'OS un vendredi.</em></p>
</blockquote>
<blockquote>
<p><em>Prenez soin de toujours tester vos conteneurs Docker sur un environnement de staging après une mise à jour système.</em></p>
</blockquote>
<blockquote>
<p><em>"Works on my machine" — jusqu'à la mise à jour du kernel.</em></p>
</blockquote>
<h2 id="sources-et-references">Sources et Références</h2>
<ul>
<li><a href="https://docs.oracle.com/en/java/javase/17+containers/" rel="noopener noreferrer">Oracle Docs : Java Container Support</a> </li>
<li><a href="https://ubuntu.com/blog/ubuntu-25-10" rel="noopener noreferrer">Ubuntu 25.10 Release Notes</a></li>
<li><a href="https://docker-java.readthedocs.io/" rel="noopener noreferrer">Docker &amp; Java : Best Practices</a></li>
<li><a href="https://www.kernel.org/doc/Documentation/cgroup-v2.txt" rel="noopener noreferrer">cgroup v2 kernel documentation</a></li>
<li><a href="https://systemd.io/" rel="noopener noreferrer">Systemd News : Changes in unified cgroup hierarchy handling (v256+)</a></li>
</ul>]]>
    </content>
  </entry>
  <entry xml:lang="fr">
    <id>https://ktherage.github.io/fr/blog/2026/my-eventsubscriber-silenced-errors/</id>
    <title>Mon EventSubscriber masquait les erreurs, voici pourquoi</title>
    <published>2026-04-13T00:00:00+00:00</published>
    <link href="https://ktherage.github.io/fr/blog/2026/my-eventsubscriber-silenced-errors/" rel="alternate" type="text/html" />
    <content type="html">
      <![CDATA[<p>Un ticket Jira est apparu : <em>« Il y a un bug étrange qui empêche les utilisateurs d'accéder à une page à ce moment-là de la journée. »</em>
Les logs indiquaient plusieurs fois : <em>« [ce jour T cette heure] request.ERROR: Uncaught PHP Exception Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException: "Access denied to that resource." at WhitelistSubscriber.php line 99 »</em></p>
<p>Je n'avais aucune idée au début… 😅 Voici comment j'ai compris.</p>
<hr>
<h2 id="la-configuration">La configuration</h2>
<p>J'avais un EventSubscriber qui vérifiait l'accès aux pages basé sur une liste blanche de routes. C'était du code legacy — le refactoriser n'était pas à l'ordre du jour à ce moment-là.</p>
<pre><code class="language-php hljs php" translate="no"><span class="hljs-meta">&lt;?php</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">RequestEvent</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-class"><span class="hljs-keyword">class</span> <span class="hljs-title">WhitelistRouteSubscriber</span> <span class="hljs-keyword">implements</span> <span class="hljs-title">EventSubscriberInterface</span>
</span>{
    <span class="hljs-keyword">private</span> <span class="hljs-keyword">const</span> WHITELISTED_ROUTES = [
        <span class="hljs-string">'app_login'</span>,
        <span class="hljs-string">'app_homepage'</span>,
        <span class="hljs-string">'app_healthcheck'</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> [
            KernelEvents::REQUEST =&gt; [<span class="hljs-string">'onKernelRequest'</span>, <span class="hljs-number">0</span>],
        ];
    }

    <span class="hljs-keyword">public</span> <span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">onKernelRequest</span><span class="hljs-params">(RequestEvent $event)</span>: <span class="hljs-title">void</span>
    </span>{
        $request = $event-&gt;getRequest();
        $route = $request-&gt;attributes-&gt;get(<span class="hljs-string">'_route'</span>);

        <span class="hljs-comment">// Allow whitelisted routes</span>
        <span class="hljs-keyword">if</span> (in_array($route, <span class="hljs-keyword">self</span>::WHITELISTED_ROUTES, <span class="hljs-keyword">true</span>)) {
            <span class="hljs-keyword">return</span>;
        }

        <span class="hljs-comment">// Deny access for non-whitelisted routes</span>
        <span class="hljs-keyword">throw</span> <span class="hljs-keyword">new</span> AccessDeniedHttpException(<span class="hljs-string">'Route not whitelisted'</span>);
    }
}</code></pre>
<p>Objectif : Bloquer toutes les routes sauf la liste blanche. Simple, non ?</p>
<hr>
<h2 id="le-probleme">Le problème</h2>
<p>Les logs montraient des <code translate="no">AccessDeniedHttpException</code> sur des routes que je savais être dans la liste blanche. Premier geste classique : mettre un <code translate="no">dump()</code> dans le subscriber pour voir ce qui arrivait.</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">onKernelRequest</span><span class="hljs-params">(RequestEvent $event)</span>: <span class="hljs-title">void</span>
</span>{
    $request = $event-&gt;getRequest();
    $route = $request-&gt;attributes-&gt;get(<span class="hljs-string">'_route'</span>);

    dump($route); <span class="hljs-comment">// 🔍 Voyons ce qui se passe</span>
    <span class="hljs-comment">// ...</span>
}</code></pre>
<p>Première découverte surprenante : <strong>le subscriber était appelé deux fois</strong> pour une seule requête. Le premier appel avait la route attendue, le second avait <code translate="no">$route = null</code>.</p>
<p>Question évidente : <em>pourquoi <code translate="no">_route</code> est-il null ?</em></p>
<p>J'ai creusé plus loin avec <code translate="no">dump($request-&gt;getPathInfo())</code> pour voir quelle URL était traitée lors du second appel :</p>
<pre><code translate="no">// 1er appel
dump($request-&gt;getPathInfo()); // "/foo"

// 2e appel
dump($request-&gt;getPathInfo()); // "/foo" ← identique. Attends, quoi ?</code></pre>
<p>Même URL, appelée deux fois. Cela n'avait aucun sens — si c'était la même requête, pourquoi <code translate="no">_route</code> était-il null la deuxième fois ? Je tournais en rond.</p>
<p>J'ai donc dumpé l'objet <code translate="no">$event</code> complet pour avoir plus de contexte, et j'ai réduit le champ à <code translate="no">_controller</code> dans les attributs de la requête :</p>
<pre><code class="language-php hljs php" translate="no">dump($request-&gt;attributes-&gt;get(<span class="hljs-string">'_controller'</span>));
<span class="hljs-comment">// "Symfony\Component\HttpKernel\Controller\ErrorController"</span></code></pre>
<p>Voilà. <code translate="no">_controller</code> ne pointait pas vers mon code du tout. Symfony avait forgé une toute nouvelle requête vers son propre <code translate="no">ErrorController</code>, réutilisant l'URL d'origine — ce qui explique pourquoi <code translate="no">getPathInfo()</code> était si trompeur — mais en contournant complètement le routeur. C'est pourquoi <code translate="no">_route</code> était null.</p>
<hr>
<h2 id="cause-racine">Cause racine</h2>
<p>Le flux réel était :</p>
<pre><code translate="no">Requête → /foo
  └── WhitelistSubscriber (1er appel) → _route = 'app_foo' ✅ Accès accordé
      └── Controller → lève RealException 💥
          └── Symfony l'attrape
              └── Sous-requête → ErrorController (contourne le routeur, pas de _route)
                  └── WhitelistSubscriber (2e appel) → _route = null ❌ AccessDenied levé
                      └── RealException est maintenant silencieuse 🔇</code></pre>
<p>Le piège : <strong>l'<code translate="no">AccessDeniedHttpException</code> du subscriber masquait complètement l'exception originale</strong> — celle qui contenait réellement les informations de débogage utiles.</p>
<p>Lorsqu'une exception est levée, le <code translate="no">HttpKernel</code> de Symfony distribue un événement <code translate="no">KernelEvents::EXCEPTION</code>, puis délègue le rendu de l'erreur à <code translate="no">ErrorController</code> via une sous-requête interne. Cette sous-requête réutilise l'URL d'origine — ce qui explique pourquoi <code translate="no">getPathInfo()</code> était trompeur — mais elle contourne complètement la couche de routage, laissant <code translate="no">_route</code> à <code translate="no">null</code>.</p>
<hr>
<h2 id="la-solution">La solution</h2>
<p>Vérifiez si la requête est la requête principale (pas une sous-requête) :</p>
<pre><code class="language-php hljs php" translate="no"><span class="hljs-meta">&lt;?php</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">RequestEvent</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-class"><span class="hljs-keyword">class</span> <span class="hljs-title">WhitelistRouteSubscriber</span> <span class="hljs-keyword">implements</span> <span class="hljs-title">EventSubscriberInterface</span>
</span>{
    <span class="hljs-keyword">private</span> <span class="hljs-keyword">const</span> WHITELISTED_ROUTES = [
        <span class="hljs-string">'app_login'</span>,
        <span class="hljs-string">'app_homepage'</span>,
        <span class="hljs-string">'app_healthcheck'</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> [
            KernelEvents::REQUEST =&gt; [<span class="hljs-string">'onKernelRequest'</span>, <span class="hljs-number">0</span>],
        ];
    }

    <span class="hljs-keyword">public</span> <span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">onKernelRequest</span><span class="hljs-params">(RequestEvent $event)</span>: <span class="hljs-title">void</span>
    </span>{
        <span class="hljs-comment">// Skip sub-requests (like error handling)</span>
        <span class="hljs-keyword">if</span> (!$event-&gt;isMainRequest()) {
            <span class="hljs-keyword">return</span>;
        }

        $request = $event-&gt;getRequest();
        $route = $request-&gt;attributes-&gt;get(<span class="hljs-string">'_route'</span>);

        <span class="hljs-comment">// Allow whitelisted routes</span>
        <span class="hljs-keyword">if</span> (in_array($route, <span class="hljs-keyword">self</span>::WHITELISTED_ROUTES, <span class="hljs-keyword">true</span>)) {
            <span class="hljs-keyword">return</span>;
        }

        <span class="hljs-comment">// Deny access for non-whitelisted routes</span>
        <span class="hljs-keyword">throw</span> <span class="hljs-keyword">new</span> AccessDeniedHttpException(<span class="hljs-string">'Route not whitelisted'</span>);
    }
}</code></pre>
<p><code translate="no">isMainRequest()</code> retourne <code translate="no">false</code> pour toute sous-requête interne — gestion d'erreurs, fragments ESI, <code translate="no">hinclude</code> — donc votre logique ne s'exécute que sur les vraies requêtes distribuées par le routeur.</p>
<blockquote>
<p><strong>Note :</strong> <code translate="no">isMainRequest()</code> a remplacé l'ancien <code translate="no">isMasterRequest()</code> dans Symfony 5.3. Si vous êtes sur une version plus ancienne, utilisez <code translate="no">isMasterRequest()</code> à la place.</p>
</blockquote>
<hr>
<h2 id="conclusion">Conclusion</h2>
<p>Chaque fois que votre subscriber fait quelque chose de destructeur — lever une exception, rediriger, définir une réponse — demandez-vous : <em>que se passe-t-il quand Symfony appelle ceci sur une sous-requête ?</em></p>
<p>Les sous-requêtes sont omniprésentes dans Symfony : gestion d'erreurs, ESI, fragments. Elles n'ont pas le même contexte qu'une requête principale, et votre subscriber ne connaît pas la différence à moins que vous ne lui disiez.</p>
<p><code translate="no">isMainRequest()</code> est cette vérification. Faites-en un réflexe. 🎉</p>]]>
    </content>
  </entry>
  <entry xml:lang="fr">
    <id>https://ktherage.github.io/fr/blog/2026/gitignore-blacklisting-whitelisting/</id>
    <title>Déboguer le .gitignore de Git : Pourquoi la liste blanche dans les sous-répertoires échoue</title>
    <published>2026-03-25T00:00:00+00:00</published>
    <link href="https://ktherage.github.io/fr/blog/2026/gitignore-blacklisting-whitelisting/" rel="alternate" type="text/html" />
    <content type="html">
      <![CDATA[<h2 id="introduction">Introduction</h2>
<p>Lorsque vous travaillez avec Git, il est courant d'utiliser <code translate="no">.gitignore</code> pour exclure des fichiers et des répertoires. Mais parfois, même des règles bien intentionnées peuvent conduire à un comportement inattendu — en particulier lorsqu'il s'agit de répertoires imbriqués. Dans cet article, je vais vous présenter un exemple concret d'une règle <code translate="no">.gitignore</code> destinée à garder un projet propre qui a fini par cacher des fichiers importants, et comment nous l'avons corrigée.</p>
<hr>
<h2 id="la-configuration">La configuration</h2>
<p>Je voulais garder le répertoire <code translate="no">.tools/</code> propre, en ne suivant que <code translate="no">composer.json</code>, <code translate="no">composer.lock</code> et le fichier <code translate="no">.gitignore</code> lui-même. Mon <code translate="no">.tools/.gitignore</code> initial ressemblait à ceci :</p>
<pre><code class="language-gitignore" translate="no">*
!.gitignore
!composer.json
!composer.lock</code></pre>
<p>Objectif : Ne suivre que composer.json, composer.lock et .gitignore dans .tools/ et ses sous-répertoires, en ignorant tout le reste.</p>
<hr>
<h2 id="le-probleme">Le problème</h2>
<p>Après avoir poussé ce changement, un collègue a signalé que son fichier composer.lock dans <code translate="no">.tools/rector/</code> était ignoré. Nous avons utilisé la commande suivante pour déboguer :</p>
<pre><code class="language-bash hljs bash" translate="no">$ git check-ignore -v .tools/rector/composer.lock
.tools/.gitignore:1:*     .tools/rector/composer.lock</code></pre>
<hr>
<h2 id="cause-racine">Cause racine</h2>
<p>La règle de Git : <em>« Il n'est pas possible de ré-inclure un fichier si un répertoire parent de ce fichier est exclu. »</em></p>
<p>Le motif <code translate="no">*</code> ignore à la fois les fichiers et les répertoires, ce qui signifie que Git ne regarde jamais à l'intérieur de <code translate="no">.tools/rector/</code> — donc les règles de liste blanche pour <code translate="no">composer.json</code> et <code translate="no">composer.lock</code> ne s'appliquent jamais.</p>
<hr>
<h2 id="la-solution">La solution</h2>
<p>Après débogage, nous avons mis à jour le <code translate="no">.gitignore</code> pour permettre explicitement la traversée des répertoires et ré-inclure les fichiers nécessaires :</p>
<pre><code class="language-gitignore" translate="no"># Ignore all files and directories at this level
*

# But allow Git to inspect subdirectories
!*/

# Explicitly ignore vendor directories
vendor

# Whitelist composer.json in any subdirectory
!*/composer.json

# Whitelist composer.lock in any subdirectory
!*/composer.lock

# Always keep this .gitignore file
!.gitignore</code></pre>
<hr>
<h2 id="points-cles-a-retenir">Points clés à retenir</h2>
<table>
<thead>
<tr>
<th>Répertoire/Fichier</th>
<th>Règle appliquée</th>
<th>Résultat</th>
</tr>
</thead>
<tbody>
<tr>
<td>.tools/</td>
<td><code translate="no">*</code></td>
<td>Ignoré</td>
</tr>
<tr>
<td>.tools/rector/</td>
<td><code translate="no">!*/</code></td>
<td>Inspecté</td>
</tr>
<tr>
<td>.tools/rector/vendor</td>
<td><code translate="no">vendor</code></td>
<td>Ignoré</td>
</tr>
<tr>
<td>.tools/rector/composer.json</td>
<td><code translate="no">!*/composer.json</code></td>
<td>Suivi</td>
</tr>
</tbody>
</table>
<ul>
<li><strong>La traversée des répertoires par Git :</strong> Lorsque vous utilisez <code translate="no">*</code> pour tout ignorer, Git ne regardera pas à l'intérieur des répertoires sauf si vous l'autorisez explicitement avec <code translate="no">!*/</code>.</li>
<li><strong>Tester vos règles :</strong> Testez toujours votre <code translate="no">.gitignore</code> avec <code translate="no">git check-ignore -v &lt;fichier&gt;</code> et <code translate="no">git status</code> pour vous assurer que les fichiers attendus sont suivis.</li>
<li><strong>L'ordre a son importance :</strong> Placez les exclusions générales en premier, puis ré-incluez les fichiers ou répertoires spécifiques.</li>
<li><strong>Pièges courants :</strong> N'oubliez pas de ré-exclure les répertoires comme <code translate="no">vendor</code> après la mise en liste blanche, sinon ils seront inclus dans votre dépôt.</li>
</ul>
<hr>
<h2 id="conclusion">Conclusion</h2>
<p>Déboguer les problèmes de <code translate="no">.gitignore</code> peut être délicat, mais comprendre comment Git évalue la traversée des répertoires et la correspondance des motifs rend les choses beaucoup plus faciles. Testez toujours vos règles avec des répertoires imbriqués avant de commiter, et n'hésitez pas à utiliser <code translate="no">git check-ignore</code> pour vérifier votre configuration.</p>]]>
    </content>
  </entry>
</feed>
