{
  "data": [
    {
      "type": "blog",
      "id": "fr/blog/2026/building-a-rag-powered-code-review-assistant",
      "url": "https://ktherage.github.io/fr/blog/2026/building-a-rag-powered-code-review-assistant/",
      "attributes": {
        "alias": "/blog/building-a-rag-powered-code-review-assistant/",
        "title": "Construire un assistant de code review RAG avec PHP, Ollama et Qdrant",
        "date": "2026-08-10T00:00:00+00:00",
        "canonical_url": "https://ktherage.github.io/fr/blog/building-a-rag-powered-code-review-assistant/",
        "description": "Comment j'ai transformé 20 ans de code reviews Symfony en assistant IA local avec PHP, Ollama et Qdrant — sans GPU.",
        "cover": {"image":"img/pexels-cottonbro-6153344.jpg","alt":"Gros plan d'un poing humain frappant une main prothétique, symbolisant la technologie et la connexion humaine.","caption":"Photo par <a href=\"https://www.pexels.com/fr-fr/@cottonbro/\">cottonbro studio</a> sur <a href=\"https://www.pexels.com\">Pexels</a>"},
        "published": true,
        "tags": ["AI","Symfony","PHP","RAG","MCP","LLM","Ollama","Qdrant"],
        "repository": "https://github.com/ktherage/symfony-review-mcp",
        "excerpt": "Demandez à un LLM de review votre code et vous obtiendrez des conseils génériques. Donnez-lui 10 000 code reviews du Core Symfony en référence — et il produira des reviews qui sonnent comme si nicolas-grekas et stof avaient relu votre PR. Voici comment j'ai construit un pipeline RAG en 100% PHP pour y parvenir.",
        "body": "Les [LLM](https://fr.wikipedia.org/wiki/Grand_mod%C3%A8le_de_langage) sont excellents pour produire des code reviews qui sonnent juste. Mais « sonner juste » n&#039;est pas la même chose qu&#039;ê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.\n\nSymfony a plus de **20 ans de code reviews publiques** sur [GitHub](https://github.com/). Chaque PR mergée contient des commentaires de [nicolas-grekas](https://github.com/nicolas-grekas), [stof](https://github.com/stof), [dunglas](https://github.com/dunglas), [xabbuh](https://github.com/xabbuh), et des dizaines d&#039;autres reviewers de la core team et de contributeurs. C&#039;est une mine d&#039;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.\n\nLe problème ? Personne n&#039;avait construit de moteur de recherche pour l&#039;exploiter. Alors je l&#039;ai fais 🤣.\n\n## Le lore derrière cette idée folle\n\nCette histoire prends sa source au [Symfony Live de Paris](https://live.symfony.com/). Comme vous l&#039;imaginez, celui de cette année était très orienté IA. J&#039;ai vu bon nombre de Talks en parlé et j&#039;ai voulu jouer un peu avec cette nouveautée mais jusque là, je n&#039;avais pas de cas concrét.\n\nJ&#039;y ai assisté au un Talk de [Grégoire Pineau](https://github.com/lyrixx) ou il expliquais comment avec [Symfony AI](https://ai.symfony.com/), [Clickhouse](https://clickhouse.com/fr) et [redirection.io](https://redirection.io/) il avait mené à bien la migration d&#039;un site E-Commerce en réduissant la perte de trafic.\n\nPlus tard est arrivé le [Console Bundle](https://symfony.com/blog/new-in-symfony-8-1-http-less-symfony-applications).\n\nJ&#039;avais déjà demandé à un LLM de relire les modifications que j&#039;avais faites _(sur des projets perso bien sûr)_ et, comme vous vous en doutez, j&#039;ai obtenu des conseils du genre `pensez à utiliser l&#039;injection de dépendances`, `peut-être extraire cette logique dans un service` ou `pense a vérifier le code style`. 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.\n\nSortant du Symfony Live, m&#039;est venu une idée un peu dingue.\nEt si je pouvais demander au même LLM : \n&gt; « Review ce code comme le ferait stof »\n\nJ&#039;aurais des retours ultra pointu et un code qui en ressortira grandi.\n\nOu\n\n&gt; « Review ce code comme le ferait n&#039;importe quel contributeur Symfony »\n\nJ&#039;aurais alors le point de vu de l&#039;ensemble de la communauté sur le code que je viens de créé. \n\nOu bien même\n\n&gt; « Review ce code comme le ferait n&#039;importe quel membre de la core team Symfony »\n\nJ&#039;aurais un panel d&#039;experts à ma disposition pour m&#039;expliquer ce qui ne vas pas dans ce que j&#039;ai fait.\n\nC&#039;est ce que fait **Symfony Reviewer MCP** : un [moteur de recherche sémantique (RAG)](https://fr.wikipedia.org/wiki/G%C3%A9n%C3%A9ration_%C3%A0_enrichissement_contextuel) sur l&#039;ensemble des code reviews historiques de Symfony accessibles via l&#039;API [GitHub](https://docs.github.com/en/rest), exposé via le [Model Context Protocol (MCP)](https://fr.wikipedia.org/wiki/Model_Context_Protocol) le tout avec [Symfony AI](https://ai.symfony.com/) et dans une application [Symfony HTTP-Less](https://symfony.com/blog/new-in-symfony-8-1-http-less-symfony-applications).\n\nJe l&#039;ai construit en [PHP 8.5](https://www.php.net/releases/8.5/) avec [Symfony 8.1](https://symfony.com/), utilisant [Ollama](https://ollama.com/) en local pour la vectorisation avec un model issu de [huggingface.co](https://huggingface.co/) (embeddinggemma-300m, 768 dimensions) et [Qdrant](https://qdrant.tech/) comme base de donnée vectorielle.\n\nAucun GPU requis si on accepte la contrepartie, l&#039;indexation complète des reviews historiques s&#039;est exécutée sur le CPU de ma machine _pendant plusieurs jours_ 😅. Pour un projet ponctuel, ce compromis m&#039;a semblé largement acceptable et &quot;cost-efficient&quot;.\n\nComment ça fonctionne ?\n\n## Architecture globale\n\nL&#039;architecture globale se découpe en deux gros blocs :\n1. **La génération du RAG :** \n    1. Avec la récupération des données et leurs mise en cache\n    2. Avec la génération du dataset et l&#039;indexation dans [Qdrant](https://qdrant.tech/)\n2. **Le serveur MCP**\n\n### La génération du RAG\n\nVoici le pipeline de génération RAG complet :\n\nNe vous inquiétez pas si ce schéma paraît dense, je vais parcourir chaque étape du pipeline dans le reste de l&#039;article, depuis la récupération des reviews GitHub jusqu&#039;à la recherche sémantique.\n\n&lt;pre class=&quot;mermaid d-flex flex-column m-2 justify-content-center align-items-center&quot;&gt;\nflowchart TD\n    A[&quot;API GitHub (symfony/symfony)&quot;] --&gt; B[&quot;PullsFetcher (PR mergées uniquement)&quot;]\n    B --&gt; C[&quot;ReviewsFetcher (commentaires + réponses)&quot;]\n    C --&gt; D[&quot;DatasetGenerator (var/dataset/pull-{id}.txt)&quot;]\n    D --&gt; E[&quot;Builder::build&quot;]\n    E --&gt; F[&quot;Ollama (embeddinggemma-300m)&quot;]\n    F --&gt; G[&quot;Qdrant (collection: reviews)&quot;]\n&lt;/pre&gt;\n\n#### Récupération des données\n\nAvant que toute cette mécanique de décorateurs HTTP ait de l&#039;importance, il faut d&#039;abord parcourir [l&#039;API GitHub](https://docs.github.com/en/rest) et décider ce qui mérite d&#039;être gardé.\n\n`PullsFetcher` pagine `GET /repos/symfony/symfony/pulls?state=all&amp;per_page=100`, en ne gardant que les PR dont `merged_at` n&#039;est pas nul — inutile d&#039;entraîner le système sur des idées rejetées. Plutôt que de paginer aveuglément jusqu&#039;à tomber sur une page vide, il envoie d&#039;abord une unique requête `HEAD` et lit le nombre total de pages directement dans le [header `Link`](https://docs.github.com/en/rest/using-the-rest-api/using-pagination-in-the-rest-api). Cet appel `HEAD` est d&#039;ailleurs exactement la raison d&#039;être de `BLACKLISTED_PATTERN` : c&#039;est une requête de découverte, pas quelque chose qui mérite d&#039;être caché 365 jours.\n\n`ReviewsFetcher` parcourt ensuite [`GET /repos/symfony/symfony/pulls/{id}/comments`](https://docs.github.com/en/rest/pulls/comments) pour chaque PR et reconstruit le véritable arbre de conversation — les commentaires parents avec leurs réponses attachées. Le piège : l&#039;API GitHub ne garantit pas l&#039;ordre des commentaires. Si une réponse arrive avant son parent, `ReviewsFetcher` la met de côté dans un pool temporaire (`$repliesTempPool`) 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&#039;importe quel thread où trois personnes se disputent sur tabs vs espaces dans le désordre perdrait silencieusement des réponses.\n\n##### La chaîne de décorateurs HTTP : Logging &amp; Caching\n\n###### L&#039;architecture\n\nLes deux fetchers passent par la même petite [chaîne de décorateurs HTTP](https://symfony.com/doc/current/http_client.html#decorating-the-client) :\n\n&lt;pre class=&quot;mermaid d-flex flex-column m-2 justify-content-center align-items-center&quot;&gt;\nflowchart TD\n    A[&quot;GithubHttpClient (scoping + auth Bearer)&quot;] --&gt; B[&quot;CachedHttpClient (cache filesystem, TTL 365j)&quot;]\n    B --&gt; C[&quot;LoggedHttpClient (logging structuré)&quot;]\n    C --&gt; D[&quot;HttpClient::create() (Symfony natif)&quot;]\n&lt;/pre&gt;\n\nChaque décorateur ajoute une responsabilité :\n\n```php\nfinal readonly class CachedHttpClient implements HttpClientInterface, ResetInterface\n{\n    public function __construct(\n        private HttpClientInterface $client,\n        private FilesystemAdapter $cache,\n        private LoggerInterface $logger,\n        private array $blacklistedPatterns = [],\n    ) {\n    }\n\n    public function request(string $method, string $url, array $options = []): ResponseInterface\n    {\n        $pattern = array_map(static fn (string $pattern): string =&gt; preg_quote($pattern, &#039;#&#039;), $this-&gt;blacklistedPatterns)\n            |&gt; (static fn ($x): string =&gt; implode(&#039;|&#039;, $x))\n            |&gt; (static fn (string $x): string =&gt; \\sprintf(&#039;#^%s$#&#039;, $x))\n        ;\n        $httpCall = $method.&#039; &#039;.$url;\n        if (preg_match($pattern, $httpCall, $matches)) {\n            return $this-&gt;client-&gt;request($method, $url, $options);\n        }\n\n        $key = md5($method.$url);\n        $cacheItem = $this-&gt;cache-&gt;getItem($key);\n        if ($cacheItem-&gt;isHit()) {\n            return $cacheItem-&gt;get();\n        }\n\n        $response = new CachedResponse($this-&gt;client-&gt;request($method, $url, $options));\n\n        $cacheItem-&gt;set($response);\n        $this-&gt;cache-&gt;save($cacheItem);\n\n        return $response;\n    }\n}\n```\n\n###### Le problème de sérialisation en chemin\n\nCette chaîne contient un piège que j&#039;ai déjà documenté dans mon article précédent — [`HttpClient::getInfo()`](https://symfony.com/doc/current/http_client.html#information-related-to-the-response) de Symfony contient une clé `pause_handler` avec une `Closure`, impossible à sérialiser. La classe `CachedResponse` gère cela en filtrant les valeurs non sérialisables :\n\n```php\nfinal readonly class CachedResponse implements ResponseInterface\n{\n    private int $statusCode;\n\n    /** @var array&lt;string, list&lt;string&gt;&gt; */\n    private array $headers;\n\n    private string $content;\n\n    /** @var array&lt;string|int, mixed&gt; */\n    private array $toArray;\n\n    /** @var array&lt;string|int, mixed&gt; */\n    private array $info;\n\n    public function __construct(ResponseInterface $response)\n    {\n        $this-&gt;statusCode = $response-&gt;getStatusCode();\n        $this-&gt;headers = $response-&gt;getHeaders();\n        $this-&gt;content = $response-&gt;getContent();\n        $this-&gt;toArray = $response-&gt;toArray();\n\n        /** @var array&lt;string|int, mixed&gt; $info */\n        $info = $response-&gt;getInfo();\n        $this-&gt;info = array_filter($info, static fn ($v): bool =&gt; !$v instanceof \\Closure);\n    }\n}\n```\n\nSans ce filtre, [`FilesystemAdapter`](https://symfony.com/doc/current/components/cache.html) échoue silencieusement — l&#039;exception de sérialisation est attrapée par `DefaultMarshaller` avec `throwOnSerializationFailure` à `false`, et la clé de cache est discrètement ignorée.\n\n#### Génération du dataset\n\nLa commande `BuildCommand` orchestre le pipeline de vectorisation :\n\n```php\n#[AsCommand(\n    name: self::NAME,\n    description: &quot;build a RAG over Symfony&#039;s official Github repository&#039;s code review&quot;,\n    help: &#039;This command is a pre-requisites for the MCP server&#039;,\n)]\nfinal readonly class BuildCommand\n{\n    public const string NAME = &#039;mcp:build&#039;;\n\n    public function __construct(\n        private LoggerInterface $logger,\n        private DatasetGenerator $datasetGenerator,\n        private Builder $builder,\n    ) {\n    }\n\n    public function __invoke(\n        #[Option(description: &#039;Skip dataset generation and uses dataset cache&#039;, name: &#039;skip-generation&#039;, shortcut: &#039;G&#039;)]\n        bool $skipGeneration = false,\n        #[Option(description: &#039;Skip build of dataset cache&#039;, name: &#039;skip-build&#039;, shortcut: &#039;B&#039;)]\n        bool $skipBuild = false,\n    ): int {\n        try {\n            if (!$skipGeneration) {\n                $this-&gt;datasetGenerator-&gt;generate();\n            }\n\n            if (!$skipBuild) {\n                $this-&gt;builder-&gt;build();\n            }\n\n            return Command::SUCCESS;\n        } catch (\\Throwable $throwable) {\n            $this-&gt;logger-&gt;error($throwable-&gt;getMessage());\n\n            return Command::FAILURE;\n        }\n    }\n}\n```\n\n##### Le fichier de base\n\nUne fois les données récupérées, `DatasetGenerator` transforme chaque PR et ses reviews en fichier texte structuré :\n```\n[PULL_REQUEST]\n    id: 54321\n    author: nicolas-grekas\n    author_association: MEMBER\n    description:\n        [HttpKernel] Fix edge case in exception handling\n\n[REVIEWS]\n    [REVIEW_1234]\n        replyTo:\n        reviewer: stof\n        reviewer_association: MEMBER\n        file: src/Component/HttpKernel/Event/ExceptionEvent.php\n        diff:\n            @@ -88,7 +88,7 @@\n             public function getThrowable(): ?\\Throwable\n             {\n        comment:\n            We should keep the original exception here,\n            the wrapper is only for internal use.\n\n        reactions:\n            +1: 5\n            -1: 0\n            laugh: 0\n            hooray: 0\n            confused: 0\n            heart: 0\n            rocket: 0\n            eyes: 0\n```\n\nCes fichiers vivent dans `var/dataset/pull-{id}.txt` et servent de vérité terrain pour la vectorisation.\n\n\n##### Intégration Qdrant\n\nLa base vectorielle est câblée dans le conteneur comme `StoreInterface`, via le `StoreFactory` de [Qdrant](https://qdrant.tech/) :\n\n```php\n-&gt;set(StoreInterface::class, Store::class)\n    -&gt;autowire()\n    -&gt;factory(StoreFactory::create(...))\n    -&gt;args([\n        &#039;$collectionName&#039; =&gt; &#039;reviews&#039;,\n        &#039;$endpoint&#039; =&gt; env(&#039;QDRANT_DSN&#039;),\n        &#039;$httpClient&#039; =&gt; service(LoggedHttpClient::class),\n        &#039;$embeddingsDimension&#039; =&gt; 768,\n        &#039;$embeddingsDistance&#039; =&gt; &#039;Cosine&#039;,\n    ])\n\n-&gt;set(VectorizerInterface::class, Vectorizer::class)\n    -&gt;autowire()\n    -&gt;args([\n        &#039;$model&#039; =&gt; &#039;hf.co/ggml-org/embeddinggemma-300m-qat-q8_0-GGUF:Q8_0&#039;,\n    ])\n```\n\nDeux détails valent le détour. \n1. Le `Vectorizer` utilise exactement le même modèle [Ollama](https://ollama.com/) qu&#039;au build — un modèle d&#039;embedding `hf.co/ggml-org/embeddinggemma-300m-qat-q8_0-GGUF:Q8_0` produisant des vecteurs à 768 dimensions.\nCette 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.\n2. La base réutilise le décorateur `LoggedHttpClient`, donc chaque aller-retour vers Qdrant bénéficie d&#039;un logging structuré par-dessus le client HTTP natif de Symfony.\n\n##### Vectorisation et stockage\n\nC&#039;est là que les choses se gâtent, cette partie à elle seule m&#039;a pris des jours.\n\n###### Qu&#039;est qu&#039;il se passe à la vectorisation ?\n\nLe fichier issu de `var/dataset/pull-{id}.txt` sont lus puis envoyés a [Ollama](https://ollama.com/) pour demander à un modèle d&#039;embedding, dans mon cas `hf.co/ggml-org/embeddinggemma-300m-qat-q8_0-GGUF:Q8_0`, qui va en générer un vecteur de 768 dimensions _(chiffre qui dépends du modèle d&#039;embedding)_ avant d&#039;être renvoyé à symfony-ai par Ollama pour enfin être sauvegarder dans un espace vectoriel dans [Qdrant](https://qdrant.tech/)\n\n&lt;pre class=&quot;mermaid d-flex flex-column m-2 justify-content-center align-items-center&quot;&gt;\nsequenceDiagram\n    participant AI as Symfony AI (Builder)\n    participant Ollama\n    participant Model as embeddinggemma-300m\n    participant Qdrant\n\n    AI-&gt;&gt;Ollama: vectorize(contenu du fichier dataset)\n    Ollama-&gt;&gt;Model: inférence du modèle\n    Model--&gt;&gt;Ollama: 768 valeurs flottantes\n    Ollama--&gt;&gt;AI: Vector (768 dimensions)\n    AI-&gt;&gt;Qdrant: add(VectorDocument)\n    Qdrant--&gt;&gt;AI: confirmation\n&lt;/pre&gt;\n\nExemple de vecteur :\n\n```bash\n❯ ollama run hf.co/ggml-org/embeddinggemma-300m-qat-q8_0-GGUF:Q8_0 &#039;Hello world !&#039;\n[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]\n```\n\n###### Pourquoi une approche séquentielle ?\n\nComme expliqué en introduction, sans GPU dédié, c&#039;est mon CPU — plus précisément l&#039;iGPU intégré à mon CPU — qui doit faire le travail de vectorisation.\nMême si l&#039;iGPU partage la RAM avec le CPU et bien que 32 Go soient disponibles _(modulo l&#039;utilisation de mon système, de programme en cours, ...)_, la vitesse de la RAM n&#039;a rien à voir avec la mémoire des cartes graphiques, bien plus rapide et dédiée.\nDe 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%.\n\nRésultat, une seul vectorisation n&#039;est possible à la fois.\nEt donc, oui, un batch upsert serait plus rapide, mais le but était de construire un pipeline fonctionnel avec uniquement des ressources locales.\n\nCe n&#039;est pas une limite de PHP ou de Qdrant, seulement un compromis pragmatique lié au matériel disponible.\n\n###### Atomicité par renommage de fichiers\n\nC&#039;est la décision de design la plus intéressante. Au lieu d&#039;une table en base de données pour suivre les fichiers traités, le `Builder` utilise des `rename()` atomiques :\n\n```\npull-{id}.txt               → prêt à traiter\nprocessing_pull-{id}.txt    → en cours de vectorisation\nragged_pull-{id}.txt        → vectorisé avec succès\n```\n\n```php\npublic function build(): void\n{\n    $this-&gt;store-&gt;setup(); // ManagedStoreInterface\n\n    $this-&gt;recoverOrphanedProcessingFiles();\n\n    foreach (scandir($this-&gt;datasetDirectory) as $file) {\n        if (&#039;.&#039; === $file || &#039;..&#039; === $file\n            || str_starts_with($file, self::RAGGED_PREFIX)\n            || str_starts_with($file, self::PROCESSING_PREFIX)) {\n            continue;\n        }\n        if (!rename($this-&gt;datasetDirectory.&#039;/&#039;.$file, $this-&gt;datasetDirectory.&#039;/&#039;.(&#039;processing_&#039;.$file))) {\n            continue; // un autre processus l&#039;a pris\n        }\n\n        try {\n            $content = file_get_contents($this-&gt;datasetDirectory.&#039;/processing_&#039;.$file);\n            $vector = $this-&gt;vectorizer-&gt;vectorize($content);\n            if (768 !== \\count($vector-&gt;getData())) {\n                throw new \\RuntimeException(&#039;Wrong dimensions&#039;);\n            }\n\n            $this-&gt;store-&gt;add(new VectorDocument(\n                id: (int) preg_replace(&#039;/[^0-9]/&#039;, &#039;&#039;, $file),\n                vector: $vector,\n                metadata: new Metadata([&#039;content&#039; =&gt; $content]),\n            ));\n\n            rename($this-&gt;datasetDirectory.&#039;/processing_&#039;.$file, $this-&gt;datasetDirectory.&#039;/ragged_&#039;.$file);\n        } catch (\\Throwable $e) {\n            rename($this-&gt;datasetDirectory.&#039;/processing_&#039;.$file, $this-&gt;datasetDirectory.&#039;/&#039;.$file); // rollback\n        }\n    }\n}\n```\n\nCrash-safe par conception : si le script meurt en plein milieu, `recoverOrphanedProcessingFiles()` remet en file les orphelins `processing_*` au prochain lancement. Pas de locks, pas de base de données, pas de race conditions.\n\n\nOui, le code montre que, malgrés ce que j&#039;ai dis plus haut :\n\n&gt; Résultat, une seul vectorisation n&#039;est possible à la fois.\n\nOui, j&#039;ai quand même essayé 🤣.\n\n\n#### Utilisation\n\nLa CLI expose deux commandes :\n\n```bash\n# Pipeline complet : récupération → dataset → vectorisation\nphp bin/console mcp:build\n\n# Re-vectoriser sans re-récupérer\nphp bin/console mcp:build --skip-generation\n\n# Re-récupérer sans re-vectoriser\nphp bin/console mcp:build --skip-build\n```\n\n### Le serveur MCP\n\nPour 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.\nTout ça s&#039;effectue via le protocole MCP suivant ce pipeline d&#039;appel simplifié :\n\n&lt;pre class=&quot;mermaid d-flex flex-column m-2 justify-content-center align-items-center&quot;&gt;\nflowchart TD\n    H[&quot;Appel Tool MCP (review_as_group/person)&quot;] --&gt; I[&quot;Retriever (recherche sémantique)&quot;]\n    I --&gt; G[&quot;Qdrant (collection: reviews)&quot;]\n    I --&gt; J[&quot;Client LLM (Claude Desktop)&quot;]\n&lt;/pre&gt;\n\nLe 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 [Qdrant](https://qdrant.tech/). \n\nLe serveur MCP expose deux **tools** et quatre **prompts** :\n\n| Tool | Rôle |\n|---|---|\n| `review_as_group` | Recherche par groupe d&#039;affiliation (MEMBER, CONTRIBUTOR, NONE) |\n| `review_as_person` | Recherche par reviewer spécifique (nicolas-grekas, stof, etc.) |\n\n| Prompt | Rôle |\n|---|---|\n| `review_as_group` | Message formaté utilisant `review_as_group` |\n| `review_as_person` | Message formaté utilisant `review_as_person` |\n| `get_stofed` | Force le reviewer à « stof » — le plus prolifique reviewer du Core Symfony |\n| `hq_review` | Multi-review : interroge 14 reviewers et synthétise un rapport markdown |\n\n#### Recherche : les Tools MCP\n\nQuand un utilisateur envoie une requête via un tool MCP, voici ce qui se passe :\n1. Le tool construit une requête combinant reviewer, chemin de fichier et diff\n2. `RetrieverInterface::retrieve()` vectorise la requête via Ollama\n3. Une recherche par similarité cosinus s&#039;exécute sur Qdrant\n4. Les `VectorDocument` correspondants sont retournés\n5. Leur `metadata[&#039;content&#039;]` est extrait et assemblé en contexte\n\n&lt;pre class=&quot;mermaid d-flex flex-column m-2 justify-content-center align-items-center&quot;&gt;\nsequenceDiagram\n    participant Client as Claude Desktop\n    participant MCP as Serveur MCP (stdio)\n    participant Tool as review_as_person\n    participant Retriever\n    participant Ollama\n    participant Qdrant\n\n    Client-&gt;&gt;MCP: call_tool(review_as_person)\n    MCP-&gt;&gt;Tool: __invoke(pseudonym, file, diff, limit)\n    Tool-&gt;&gt;Retriever: retrieve(query, [&#039;limit&#039; =&gt; limit])\n    Retriever-&gt;&gt;Ollama: vectorize(query)\n    Ollama--&gt;&gt;Retriever: vecteur de la requête\n    Retriever-&gt;&gt;Qdrant: recherche par similarité cosinus\n    Qdrant--&gt;&gt;Retriever: documents les plus proches\n    Retriever--&gt;&gt;Tool: VectorDocument[]\n    Tool--&gt;&gt;MCP: reviews trouvées (texte)\n    MCP--&gt;&gt;Client: résultat du tool\n&lt;/pre&gt;\n\n```php\n#[McpTool(\n    name: self::NAME,\n    description: &#039;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`.&#039;,\n    annotations: new ToolAnnotations(&#039;Review matching file diff as github user&#039;, true, false, true, false)\n)]\nfinal readonly class ReviewAsPersonMatchingFileDiffTool\n{\n    public const string NAME = &#039;review_as_person&#039;;\n\n    public function __invoke(string $pseudonym, string $file, string $diff, int $limit): string\n    {\n        $query = &lt;&lt;&lt;TXT\n        reviewer: $pseudonym\n        file: $file\n        diff:\n        {$diff}\n        TXT;\n\n        try {\n            $retrieved = $this-&gt;retriever-&gt;retrieve($query, [&#039;limit&#039; =&gt; $limit]);\n\n            $return = [];\n            foreach ($retrieved as $document) {\n                $content = $document-&gt;getMetadata()[&#039;content&#039;] ?? null;\n                if (null === $content || !\\is_string($content)) {\n                    continue;\n                }\n                $return[] = $content;\n            }\n        } catch (\\Throwable $exception) {\n            return &#039;Error retrieving reviews: &#039;.$exception-&gt;getMessage();\n        }\n\n        if (0 === \\count($return)) {\n            return &#039;No reviews found.&#039;;\n        }\n\n        return implode(&quot;\\n\\n---\\n\\n&quot;, $return);\n    }\n}\n```\n\n#### Le Prompt HQ Review\n\nLe prompt `hq_review` est la fonctionnalité vedette. Il interroge 14 reviewers Symfony de premier plan ([GromNaN](https://github.com/GromNaN), [dunglas](https://github.com/dunglas), [welcoMattic](https://github.com/welcoMattic), [nicolas-grekas](https://github.com/nicolas-grekas), [chalasr](https://github.com/chalasr), [stof](https://github.com/stof), [yceruto](https://github.com/yceruto), [mtarld](https://github.com/mtarld), [OskarStark](https://github.com/OskarStark), [xabbuh](https://github.com/xabbuh), [lyrixx](https://github.com/lyrixx), [kbond](https://github.com/kbond), [jderusse](https://github.com/jderusse), [alexandre-daubois](https://github.com/alexandre-daubois)), 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.\n\nLe résultat est une code review qui ressemble à un mini-symposium des mainteneurs du Core Symfony — sans nécessiter leur temps.\n\n#### Utilisation\n\n```bash\ndocker build -t symfony-reviewer-mcp-cli /path/to/Dockerfile\ndocker 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\n```\n\nPuis configurer Claude Desktop (ou tout client MCP) en ajoutant le serveur à `claude_desktop_config.json` :\n\n```json\n{\n  &quot;mcpServers&quot;: {\n    &quot;symfony-reviewer&quot;: {\n      &quot;command&quot;: &quot;docker&quot;,\n      &quot;args&quot;: [\n            &quot;run&quot;, &quot;-i&quot;, &quot;--rm&quot;,\n            &quot;--add-host=host.docker.internal:host-gateway&quot;,\n            &quot;-e&quot;, &quot;QDRANT_DSN=http://host.docker.internal:6333&quot;,\n            &quot;-e&quot;, &quot;OLLAMA_DSN=http://host.docker.internal:11434&quot;,\n            &quot;symfony-reviewer-mcp-cli&quot;\n        ]\n    }\n  }\n}\n```\n\n## Les variables d&#039;env\n\nToute la configuration passe par les variables `.env` :\n\n| Variable | Rôle |\n|---|---|\n| `GITHUB_TOKEN` | [Token d&#039;accès personnel GitHub](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens) |\n| `QDRANT_DSN` | URL du service Qdrant |\n| `OLLAMA_DSN` | URL du service Ollama |\n| `BLACKLISTED_PATTERN` | Tableau JSON de patterns URL à exclure du cache |\n| `APP_VERSION` | Version affichée dans les métadonnées MCP |\n\n## Leçons apprises\n\nCe projet m&#039;a permis de comprendre comment fonctionne le protocole MCP, ce qu&#039;est un base de données vectoriel et comment l&#039;utiliser. L&#039;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&#039;autres expérimentations ou usages.\n\n### Ce qui a bien fonctionné\n\n- **Atomicité par renommage de fichiers** : Ce pattern est élégant, crash-safe et ne nécessite aucune infrastructure. Chaque développeur PHP comprend `rename()`. Pas de locks Redis, pas de migrations de base de données.\n- **Pipeline incrémental** : Relancer `mcp:build` avec des fichiers existants est un no-op. L&#039;itération est rapide — on peut ajuster la vectorisation et ne traiter que les nouveaux fichiers.\n- **Fonctionnalités PHP 8.x** : La promotion de constructeur, les propriétés readonly, l&#039;opérateur pipe (`|&gt;`), et les commandes invokables rendent le code bien plus propre.\n- **[Ollama](https://ollama.com/) en local** : embeddinggemma-300m tourne sur CPU sans problème. 768 dimensions, c&#039;est assez modeste pour des requêtes rapides mais assez riche pour la recherche sémantique sur des code reviews.\n\n### Ce qui doit être amélioré\n\nSi j&#039;avais eu une machine plus efficace avec un GPU dédié ou une RAM unifiée (👋 les propriétaires de Mac), j&#039;aurai peut-être pu changer ce qui suit :\n- **Interaction Qdrant naïve** : Les documents sont ajoutés un par un. Un batch upsert serait nettement plus rapide pour les gros volumes.\n- **Pas de mise à jour incrémentale du RAG** : Le pipeline est add-only. Il n&#039;y a pas de mécanisme &quot;builtin&quot; _(c&#039;est possible via le dashboard de qdrant)_ 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&#039;on retrouve rarement de nouveaux commentaire sur des Pull-Requests déjà mergées.\n- **Ajoutez vos propres conventions** : J&#039;ai ajouté les reviews de Symfony, mais vous pouvez vous aussi modifier et adapter le code pour qu&#039;il s&#039;appuie sur un corpus de données supplémentaire comme les revues de vos collaborateurs.\n\n### Ce que je ferais différemment\n\n1. **Vectorisation par lots** : Grouper les documents et vectoriser en lots pour un meilleur débit\n2. **Récupération asynchrone** : 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\n3. **Webhook GitHub** : Au lieu de reconstruire périodiquement, écouter les événements de PR mergées et mettre à jour le dataset de manière incrémentale\n4. **Évaluation des modèles d&#039;embeddings** : 768 dimensions fonctionne bien, mais je devrais comparer différents modèles : des modèles plus petits (comme [all-MiniLM-L6-v2](https://huggingface.co/sentence-transformers/all-MiniLM-L6-v2), 384 dimensions) pour améliorer les performances, ou des modèles beaucoup plus gros afin d&#039;évaluer le gain potentiel en qualité de recherche.\n5. **Contexte élargi : commentaires de PR et diff complet** : 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.\n6. **Exploiter le champ `metadata` pour le JSON brut de GitHub** : [`symfony/ai-store`](https://github.com/symfony/ai-store) attache à chaque `VectorDocument` un objet `Metadata` (`Symfony\\AI\\Store\\Document\\Metadata`) qui voyage jusqu&#039;au store choisi. Chez Qdrant, ce `Metadata` correspond exactement à la notion de *payload* : un objet JSON arbitraire attaché à chaque point, indexable et filtrable nativement — par exemple filtrer par `reviewer_association`, par date, ou par nombre de réactions, sans re-parser le texte du dataset. Aujourd&#039;hui, seul `content` (le texte assemblé du fichier dataset) y est stocké ; j&#039;y aurais ajouté la réponse JSON brute de l&#039;API GitHub (PR + review + reactions), pour garder une trace exploitable indépendante du format texte généré.\n7. **Exploiter les réactions du JSON GitHub**: Les réactions se prêteraient d&#039;ailleurs à mieux qu&#039;un simple filtre : elles pourraient pondérer le score de recherche lui-même, pas juste être renvoyées dans le `content`. Depuis la version 1.14, Qdrant propose une [*Formula Query*](https://qdrant.tech/documentation/search/hybrid-queries/) 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 `+1` 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&#039;être explorée.\n\n## Est-ce que ça fonctionne ?\n\nLa grande question : « est-ce que les reviews produites sont réellement meilleures ? ».\n\nRépondre Oui serait en parti faux. En effet, aujourd&#039;hui, mon évaluation reste essentiellement basée sur un sentiment générale plutôt que sur de vraies données tangibles et quantifiables.\nLes réponses générées **me** paraissent beaucoup plus dans le ton d&#039;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.\nEnfin, comme un LLM et par nature &quot;probabiliste&quot;, je ne crois pas qu&#039;il soit pertinant de dire que sur la base de mes propres observations et mon sentiment personnel seul le fonctionnement d&#039;un outil est établi.\n\nVoici un exemple de revue effectuée sur ce projet pour vous donner un idée :\n```markdown\n---\n\n### 1. fabpot\n**Files:** `src/HTTP/CachedHttpClient.php`, `src/HTTP/CachedResponse.php`\n\n&gt; Thanks for the PR! A few things before we can merge:\n&gt;\n&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.\n&gt; * Please add a test covering the &quot;non-buffered&quot; response case — `getContent()` then `toArray()` will fail on `EventSourceHttpClient` responses.\n&gt; * Do we need a `CHANGELOG` entry for the `CachedHttpClient`? I think we can keep it internal.\n&gt;\n&gt; Otherwise the approach is clean. Once the cache key is fixed, we can squash the commits.\n\n---\n\n### 2. nicolas-grekas\n**Files:** `src/HTTP/CachedResponse.php`, `src/Kernel.php`\n\n&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&#039;t call both.\n&gt;\n&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.\n&gt;\n&gt; One more: `getContent(false)` is being snapshotted eagerly in the constructor — that defeats lazy streaming for large GitHub responses. Buffer lazily.\n\n---\n\n### 3. stof\n**Files:** `src/Kernel.php`\n\n&gt; Service wiring nit: decoration replaces the decorated id, so `$httpClient =&gt; service(LoggedHttpClient::class)` gives you the `CachedHttpClient`, not the logging client. That&#039;s a scope leak — the Qdrant store is now coupled to a cache tuned for the GitHub API (see `BLACKLISTED_PATTERN`).\n&gt;\n&gt; I&#039;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.\n&gt;\n&gt; And the `json:BLACKLISTED_PATTERN` env var — document its format in the README.\n\n---\n\n### 4. weaverryan\n**Files:** `src/MCP/Tools/ReviewAsPersonMatchingFileDiffTool.php`\n\n&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.\n&gt;\n&gt; One DX thought: when no reviews are found we return `No reviews found.` — that&#039;s good. But maybe give the caller a hint that they can reduce `limit` or widen the diff? Small thing, ignore if you want.\n&gt;\n&gt; Also, the metadata `content` check with the warning log is nice defensive coding. Keep it up! 🎉\n\n---\n\n### 5. derrabus\n**Files:** `src/HTTP/CachedHttpClient.php`\n\n&gt; Two things:\n&gt;\n&gt; 1. `$key = md5($method.$url)` — please include the serialized options/body. Hash collisions here are silent correctness bugs, not just perf issues.\n&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.\n&gt;\n&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).\n\n---\n\n### 6. xabbuh\n**Files:** `src/HTTP/CachedResponse.php`\n\n&gt; I have concerns about the snapshot in the constructor:\n&gt;\n&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.\n&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.\n&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.\n\n---\n\n### 7. Tobion\n**Files:** `src/HTTP/CachedHttpClient.php`\n\n&gt; The pipeline operator chains in `request()` are over-engineered for building a regex. `implode(&#039;|&#039;, 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.\n&gt;\n&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.\n\n---\n\n### 8. mpdude\n**Files:** `src/RAG/Builder.php` (via `Store`)\n\n&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 &quot;No reviews found&quot; or partial results.\n&gt;\n&gt; Please make the build fail-fast or at least surface a summary count at the end (&quot;indexed X / failed Y&quot;) so operators know the dataset is incomplete. Right now nothing tells us that only ~1106 of 8691 documents made it in.\n\n---\n\n### 9. WouterJ\n**Files:** `src/Kernel.php`\n\n&gt; The container config reads really well — the decoration chain is easy to follow. Nice use of `env(&#039;json:BLACKLISTED_PATTERN&#039;)` and `StoreFactory::create(...)`.\n&gt;\n&gt; Minor: the `logged.http_client` vs `cached.github.http_client` distinction is muddied because both decorators use `priority: 1` and decorate each other&#039;s ids. I&#039;d give them explicit service aliases (`github.logged.http_client`, etc.) so the intent is obvious. Also the unused `&#039;stream_handler&#039;` monolog handler and the commented-out `http` transport block could be cleaned up before merge.\n\n---\n\n### 10. alexislefebvre\n**Files:** `tests/HTTP/CachedHttpClientTest.php`\n\n&gt; Nice test coverage — you test blacklist skipping, persistence across instances, `withOptions` cloning, and `reset`. 👍\n&gt;\n&gt; Missing cases I&#039;d love to see:\n&gt;\n&gt; 1. A `POST` request with a body — assert that different bodies don&#039;t collide in the cache (this would catch the `md5(method.url)` bug).\n&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.\n&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.\n\n---\n\n### 11. Nyholm\n**Files:** `src/HTTP/LoggedHttpClient.php`, `src/HTTP/CachedHttpClient.php`\n\n&gt; As the http-client component maintainer: don&#039;t re-implement caching. Symfony&#039;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).\n&gt;\n&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.\n&gt;\n&gt; Also: `FileSystemAdapter` on a single Docker container is fine, but for multi-instance deploys you&#039;ll want a shared pool (Redis). Worth a comment.\n\n---\n\n### 12. jderusse\n**Files:** `src/RAG/Builder.php`, `src/HTTP/CachedHttpClient.php`\n\n&gt; The elephant in the room: the per-document vectorization loop is serial. 8691 documents, ~10s each — that&#039;s ~24h to build the RAG, and with the cache bug most embeddings were identical (same URL → same key). That&#039;s why retrieval feels broken.\n&gt;\n&gt; Fixes I&#039;d push for:\n&gt; * Parallelize vectorization with Symfony&#039;s `AsyncResponse` / `stream()` over batches.\n&gt; * Include the body in the cache key (obviously).\n&gt; * Index the docs that failed (`Index failed` ×636) with retry/backoff.\n&gt;\n&gt; Also `Builder` should checkpoint progress so a crash doesn&#039;t restart from zero.\n\n---\n\n### 13. chalasr\n**Files:** `src/MCP/Tools/ReviewAsPersonMatchingFileDiffTool.php`, `src/Command/ServeCommand.php`\n\n&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&#039;t distinguish &quot;no data&quot; from &quot;server broken&quot;.\n&gt;\n&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.\n&gt;\n&gt; And the serve command: make sure `APP_DEBUG` is off in prod and there&#039;s a graceful shutdown on SIGTERM.\n\n---\n\n### 14. yceruto\n**Files:** `src/Kernel.php`, `src/HTTP/CachedHttpClient.php`\n\n&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.\n&gt;\n&gt; I&#039;d restructure like this:\n&gt; ```\n&gt; HttpClientInterface          # plain\n&gt;  └─ logged.http_client       # logging only (for Qdrant/Ollama)\n&gt;  └─ cached.github.http_client # cache + github token (for GitHub fetchers)\n&gt; ```\n&gt; Two separate chains, no cross-decorating. Then the cache key issue (URL-only, no body) also only affects GitHub GETs, which is safe.\n&gt;\n&gt; After that, the 14 `review_as_person` calls will stop returning the `buffering is disabled` error and start returning real reviews.\n\n---\n```\n\n## Pour conclure\n\n&lt;div style=&quot;width:100%;height:0;padding-bottom:56%;position:relative;&quot;&gt;\n    &lt;iframe src=&quot;https://giphy.com/embed/NRiRXQTwbijNba2l2l&quot; width=&quot;100%&quot; height=&quot;100%&quot; style=&quot;position:absolute&quot; frameBorder=&quot;0&quot; class=&quot;giphy-embed&quot; allowFullScreen&gt;&lt;/iframe&gt;\n&lt;/div&gt;\n\n&lt;p&gt;&lt;a href=&quot;https://giphy.com/gifs/The-Animal-Crackers-Movie-baking-try-it-NRiRXQTwbijNba2l2l&quot;&gt;via GIPHY&lt;/a&gt;&lt;/p&gt;\n\nEssayez-le vous même, le [projet](https://github.com/ktherage/symfony-review-mcp) 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 [Docker](https://www.docker.com/).\n\nPour l&#039;installer :\n\n```bash\ngit clone https://github.com/ktherage/symfony-review-mcp\ncd symfony-review-mcp\ndocker compose run --rm cli composer install\ndocker compose run --rm cli bin/console mcp:build\ndocker compose up -d\n```\n\nLa chose la plus surprenante que j&#039;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 [`symfony/ai-*`](https://github.com/symfony/ai), gèrent tout, de la décoration HTTP aux opérations sur bases vectorielles. \n\nVous n&#039;avez pas besoin de Python pour faire de la recherche sémantique.\n\nParfois, le meilleur outil pour le travail est celui que vous maîtrisez déjà."
      }
    },
    {
      "type": "blog",
      "id": "fr/blog/2026/le-cache-qui-ne-fonctionnait-pas",
      "url": "https://ktherage.github.io/fr/blog/2026/le-cache-qui-ne-fonctionnait-pas/",
      "attributes": {
        "alias": "/blog/le-cache-qui-ne-fonctionnait-pas/",
        "title": "Le cache qui ne fonctionnait pas : une histoire de sérialisation chez Symfony",
        "date": "2026-06-10T00:00:00+00:00",
        "description": "Comment une Closure dans getInfo(), une exception silencieuse, et une valeur de retour non vérifiée ont créé un faux succès parfait.",
        "cover": {"image":"img/pexels-black-hole-23522813.jpeg","alt":"Vue en noir et blanc d'un trou noir entouré d'étoiles tourbillonnantes dans une galaxie spirale","caption":"Photo par <a href=\"https://www.pexels.com/@icebergsano-427049742/\">Iceberg San</a> sur <a href=\"https://www.pexels.com\">Pexels</a>"},
        "published": true,
        "tags": ["Symfony","PHP","Debug","Cache","Sérialisation"],
        "excerpt": "Les logs disaient que le cache fonctionnait. Le filesystem pouvait en témoigner. Voici comment une Closure cachée dans getInfo(), une gestion d'exception silencieuse, et une valeur de retour ignorée ont créé un parfait cauchemar silencieux.",
        "body": "J&#039;ai eu un bug où la mise en cache des réponses HTTP semblait fonctionner d&#039;après les logs, pourtant aucun fichier n&#039;apparaissait dans `var/http_cache/`. Pas de fichiers. Pas d&#039;erreurs. Juste du silence.\n\n## Le Contexte\n\nJe 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&#039;application et l&#039;API externe que j&#039;utilise pour construire mon RAG.\n\nLa chaîne de clients HTTP suit un pattern decorator classique (du plus haut au plus bas dans la chaine de décoration):\n\n&lt;pre class=&quot;mermaid d-flex flex-column m-2 justify-content-center align-items-center&quot;&gt;\nflowchart TD\n    A[Symfony\\Component\\HttpClient\\HttpClient\\ScopingHttpClient] --&gt; B\n    B[&quot;CachedHttpClient (stratégie de cache perso)&quot;] --&gt; C\n    C[&quot;LoggedHttpClient (stratégie de logging perso)&quot;] --&gt; D\n    D[&quot;Symfony\\Component\\HttpClient\\HttpClient::create()&quot;]\n&lt;/pre&gt;\n\n`CachedHttpClient` combine `ScopingHttpClient` de Symfony (pour le scoping et l&#039;authentification auprès de l&#039;API) avec un `FilesystemAdapter` pour persister les réponses HTTP dans `var/http_cache/`. Une classe `CachedResponse` implémente `ResponseInterface` pour que les réponses mises en cache ressemblent aux réponses fraîches.\n\n## Le Symptôme\n\n```\napp.DEBUG: Storing Response to cache with key c3f36f73afae200bb284436334b6647f.\napp.DEBUG: Response stored to cache with key c3f36f73afae200bb284436334b6647f.\n```\n\nLes logs debug confirmaient les tentatives de mise en cache. Réalité : `var/http_cache/` restait vide.\n\n&lt;div class=&quot;d-flex flex-column m-2 justify-content-center align-items-center&quot;&gt;\n    &lt;iframe src=&quot;https://giphy.com/embed/NTur7XlVDUdqM&quot; width=&quot;480&quot; height=&quot;274&quot; frameBorder=&quot;0&quot; class=&quot;giphy-embed&quot; allowFullScreen&gt;&lt;/iframe&gt;\n    &lt;p&gt;&lt;a href=&quot;https://giphy.com/gifs/trump-consequences-NTur7XlVDUdqM&quot;&gt;via GIPHY&lt;/a&gt;\n&lt;/div&gt;\n\n## L&#039;analyse\n\n### 1. Le `FilesystemAdapter` la fausse piste qui m&#039;a aidé a comprendre\n\nDonc la question qui ce posait à ce moment était, Pourquoi ? Pourquoi ça ne sauvegarde pas mes reponses en cache ?\n\n### 1.1. Y-a-t-il un problème avec le système de fichiers ?\n\nJ&#039;ai d&#039;abord pensé que ça venait de `Symfony\\Component\\Cache\\Adapter\\FilesystemAdapter` et en fouillant dans les fichiers du dossier `vendor` j&#039;ai pu constater ce qui suit :\n\n&lt;pre class=&quot;mermaid d-flex flex-column m-2 justify-content-center align-items-center&quot;&gt;\nflowchart TD\n    A[&quot;Symfony\\Component\\Cache\\Adapter\\FilesystemAdapter::save()&quot;] --&gt; B\n    B[&quot;Symfony\\Component\\Cache\\Traits\\AbstractAdapterTrait::save()&quot;] --&gt; C\n    C[&quot;Symfony\\Component\\Cache\\Adapter\\AbstractAdapter::commit()&quot;] --&gt; D\n    D[&quot;Symfony\\Component\\Cache\\Traits\\FilesystemTrait::doSave()&quot;] --&gt; E\n    E[&quot;Symfony\\Component\\Cache\\Marshaller\\DefaultMarshaller::marshall()&quot;] --&gt; F{&quot;calls serialize()&quot;}\n    F[&quot;Symfony\\Component\\Cache\\Traits\\FilesystemCommonTrait::write()&quot;]\n&lt;/pre&gt;\n\n`Symfony\\Component\\Cache\\Traits\\FilesystemTrait::doSave()` appelait `Symfony\\Component\\Cache\\Marshaller\\DefaultMarshaller::marshall()` qui lui utilisait `serialize()` de PHP avant d&#039;en fournir le retour à `Symfony\\Component\\Cache\\Traits\\FilesystemCommonTrait::write()`.\n\nEn ouvrant la fonction, j&#039;ai constaté que `FilesystemCommonTrait::write()` executais la fonction `mkdir()` de PHP préfixée d&#039;un `@` qui supprimais les erreurs lié a la création du répertoire. Donc s&#039;il y avait un problème avec mon repertoire de cache, il serait tû tout simplement. J&#039;ai donc essayé de lancer un `chmod -R 777 var/http_cache/` mais en vain.\n\n### 1.2. Y-a-t-il un problème avec la serialisation ?\n\nIl ne me restais plus qu&#039;à voir si le problème pouvait venir de la serialisation. J&#039;ai donc créé un script de reproduction minimaliste pour comprendre :\n\n```bash\ndocker compose exec cli sh -c &quot;php -r &#039;\nrequire \\&quot;/srv/vendor/autoload.php\\&quot;;\n\nuse App\\HTTP\\CachedResponse;\nuse Symfony\\Component\\HttpClient\\HttpClient;\n\n\\$client = HttpClient::create();\n\\$response = \\$client-&gt;request(\\&quot;GET\\&quot;, \\&quot;https://some.api.com/foo\\&quot;, [\n    \\&quot;headers\\&quot; =&gt; [\n        \\&quot;User-Agent\\&quot; =&gt; \\&quot;Test\\&quot;,\n    ],\n]);\n\n\\$cached = new CachedResponse(\\$response);\ntry {\n    \\$serialized = serialize(\\$cached);\n    echo \\&quot;Serialization OK\\\\n\\&quot;;\n} catch (\\Exception \\$e) {\n    echo \\&quot;Serialization FAILED: \\&quot; . \\$e-&gt;getMessage() . \\&quot;\\\\n\\&quot;;\n}\n&#039; 2&gt;&amp;1\n# Sortie :\n# Serialization FAILED: Serialization of &#039;Closure&#039; is not allowed\n```\n\nL&#039;échec se produit pendant `serialize()` — bien avant les opérations de fichier. L&#039;objet `CachedResponse` contenait des données non sérialisables, dans mon cas une `Closure`.\n\n### 2. Localiser l&#039;élément non sérialisable\n\nLa nouvelle question maintenant, où est-ce que je peut avoir une `Closure` dans ma `CachedResponse`.\n\n### 2.1. CachedResponse\n\nCette classe est plutôt simple et est construite à partir d&#039;une instance de `Symfony\\Contracts\\HttpClient\\ResponseInterface`.\n\n```php\n&lt;?php\n\ndeclare(strict_types=1);\n\nnamespace App\\HTTP;\n\nuse Symfony\\Contracts\\HttpClient\\ResponseInterface;\n\nfinal class CachedResponse implements ResponseInterface\n{\n    private int $statusCode;\n    private array $headers;\n    private string $content;\n    private array $toArray;\n    private array $info;\n\n    public function __construct(ResponseInterface $response)\n    {\n        $this-&gt;statusCode = $response-&gt;getStatusCode();\n        $this-&gt;headers = $response-&gt;getHeaders();\n        $this-&gt;content = $response-&gt;getContent();\n        $this-&gt;toArray = $response-&gt;toArray();\n        $this-&gt;info = $response-&gt;getInfo();\n    }\n    \n    // ...\n}\n```\n\n### 2.2. Procédons par élimination\n\nEn procédant par élimination, il ne nous reste que `getInfo()` qui peut avoir ce genre de choses à l&#039;interieur puisque :\n- `statusCode`: retourne un `int` qui corresponds au code de statut HTTP qui est aussi un entier.\n- `headers`: retourne les entêtes HTTP qui ne sont basiquement que des tableaux d&#039;`int` ou `string`.\n- `content`: retourne le corps de la réponse qui n&#039;est qu&#039;une `string` donc aucune `Closure` là-dedans.\n- `toArray`: aurait renvoyé une exception si le `content` n&#039;avait pas été encodé en JSON.\n\nDonc il doit y avoir quelque chose d&#039;étrange que je n&#039;avais pas anticipé dans `getInfo()` et inspecter `getInfo()` a révélé le coupable ce que j&#039;ai fais via ce script :\n```bash\ndocker compose exec cli php -r &#039;\nrequire &quot;/srv/vendor/autoload.php&quot;;\nuse Symfony\\Component\\HttpClient\\HttpClient;\n\\$client = HttpClient::create();\n\\$response = \\$client-&gt;request(&quot;GET&quot;, &quot;https://api.github.com/repos/symfony/symfony/pulls/64552&quot;, [\n    &quot;headers&quot; =&gt; [&quot;Accept&quot; =&gt; &quot;application/vnd.github+json&quot;, &quot;User-Agent&quot; =&gt; &quot;Test&quot;],\n]);\nforeach (\\$response-&gt;getInfo() as \\$k =&gt; \\$v) {\n    if (\\$v instanceof \\\\Closure) echo &quot;\\$k =&gt; Closure\\\\n&quot;;\n}\n&#039;\n# Sortie :\npause_handler =&gt; Closure\n```\n\nLe client HTTP de Symfony inclut une clé `pause_handler` dans `getInfo()` contenant une `Closure` utilisée en interne pour la logique de retry (gestion des `429 Too Many Requests` avec `Retry-After`) or PHP ne peut pas sérialiser les `Closure`.\n\n### 3. Pourquoi l&#039;échec était silencieux\n\nTrois couches ont occulté la rééle cause :\n\n**Couche 1 — Valeur de retour ignorée / Mon erreure**\n\nMon erreure a été de ne pas vérifier le retour de la fonction `save()` qui, je ne l&#039;avais pas noté à ce moment là, retourne un booléen indiquant si l&#039;enregistrement a bien été effectué.\n\nJe suis donc passé de :\n```php\n$this-&gt;cache-&gt;save($cacheItem); // returns false, ignored\n$this-&gt;logger-&gt;debug(&quot;Response stored to cache with key {$key}.&quot;);\n```\n\nà:\n```php\n$saved = $this-&gt;cache-&gt;save($cacheItem);\n$this-&gt;logger-&gt;debug(&quot;Cache save: {result}&quot;, [&#039;result&#039; =&gt; $saved ? &#039;success&#039; : &#039;FAILED&#039;]);\n```\n\nCe qui m&#039;a permis d&#039;avoir des logs plus pertinent avec un message `Cache save: FAILED` qui était affiché à chaque tentatives.\nLa méthodes `save()` ne fonctionnait pas et donc mon cache n&#039;avais jamais fonctionné.\n\n**Couche 2 — Gestion d&#039;exception silencieuse dans le marshallage**\n\n`serialize()` plantais silencieusement parce que dans `Symfony\\Component\\Cache\\Marshaller\\DefaultMarshaller::marshall()` en interne et par défaut Symfony attrape les exceptions de sérialisation et peuple un tableau d&#039;id avec les serialisations échouées.\n\nVoici une version simplifiée de la fonction :\n\n```php\npublic function marshall(array $values, ?array &amp;$failed): array\n{\n    $serialized = $failed = [];\n\n    foreach ($values as $id =&gt; $value) {\n        try {\n            $serialized[$id] = serialize($value);\n        } catch (\\Exception $e) {\n            if ($this-&gt;throwOnSerializationFailure) {\n                throw new \\ValueError($e-&gt;getMessage(), 0, $e);\n            }\n            $failed[] = $id;\n        }\n    }\n\n    return $serialized;\n}\n```\n\nAvec `throwOnSerializationFailure` par défaut à `false`, les exceptions sont avalées. La clé de cache échouée va dans `$failed`, mais aucun avertissement n&#039;est émis.\n\n**Couche 3 — Sérialisation complète d&#039;un tableau de donneés mixte**\n\nStocker complètement un tableau de données mixte dans le cache a été une autre de mes erreurs, _quoique à moitié la mienne je n&#039;avais pas anticipé la `Closure` dans `getInfo()`_, avec le recule tout n&#039;est pas peut-être pas bon a conserver.\n\n## La Correction\n\n**Valider les résultats de l&#039;opération de cache:**\n\n```php\nif (!\\$this-&gt;cache-&gt;save(\\$cacheItem)) {\n    \\$this-&gt;logger-&gt;warning(&#039;Failed to save response to cache&#039;, [&#039;key&#039; =&gt; \\$key]);\n    return;\n}\n\\$this-&gt;logger-&gt;debug(&quot;Response stored to cache with key {\\$key}.&quot;);\n```\n\n**Filtrer les `\\Closure` de `getInfo()`:**\n\n```php\n\\$this-&gt;info = array_filter(\n    \\$response-&gt;getInfo(),\n    static fn (\\$v) =&gt; !\\$v instanceof \\Closure\n);\n```\n\n## Prévention\n\nCe bug n&#039;était pas un problème avec le cache de Symfony — il fonctionnait comme conçu. L&#039;échec venait de trois négligences alignées :\n\n1. **Oublier de vérifier les valeurs de retour**  \n   Les méthodes retournent des valeurs pour une raison. Traite les méthodes avec `bool` comme `save()` comme des contrats.\n\n2. **Négliger la gestion d&#039;exception silencieuse**  \n   Les frameworks priorisent parfois le silence sur la visibilité. Saisis où basculer la verbosité (`throwOnSerializationFailure: true` en dev).\n\n3. **Supposer que `getInfo()` ne contient que des données scalaires**  \n   Des internals comme `pause_handler` peuvent fuir dans les métadonnées. Valide toujours ce que tu caches."
      }
    },
    {
      "type": "blog",
      "id": "fr/blog/2026/dealing-with-isolated-phpstan-1-and-the-phpunit-13-blindspot",
      "url": "https://ktherage.github.io/fr/blog/2026/dealing-with-isolated-phpstan-1-and-the-phpunit-13-blindspot/",
      "attributes": {
        "alias": "/blog/dealing-with-isolated-phpstan-1-and-the-phpunit-13-blindspot/",
        "title": "Gérer l'isolation de PHPStan 1 et l'angle mort de PHPUnit 13",
        "date": "2026-05-29T00:00:00+00:00",
        "description": "Comment l'isolation de vos outils de QA peut causer des erreurs fantômes 'unknown class TestCase' après une mise à jour vers PHPUnit 13, et comment la version 2.0 de PHPStan résout le problème.",
        "cover": {"image":"img/pexels-puzzle-missing-piece.jpg","alt":"Puzzle blanc avec une pièce manquante révélant un fond bleu","caption":"Photo par <a href=\"[https://www.pexels.com/@karolina-grabowska/](https://www.pexels.com/@karolina-grabowska/)\">Karolina Grabowska</a> sur <a href=\"[https://www.pexels.com](https://www.pexels.com)\">Pexels</a>"},
        "published": true,
        "tags": ["PHPStan","PHPUnit","QA Tools","Testing"],
        "excerpt": "Isoler PHPStan dans un sous-dossier séparé est une excellente idée pour éviter l'enfer des dépendances—jusqu'à ce que vous passiez à PHPUnit 13 et que votre pipeline d'analyse statique devienne complètement aveugle. Voici comment y remédier.",
        "body": "Nous adorons les outils de développement isolés. Placer PHPStan, Rector ou PHP CS Fixer dans des sous-répertoires distincts comme `.tools/phpstan/` avec leur propre `composer.json` est un excellent moyen d&#039;éviter l&#039;enfer des dépendances dans votre projet racine.\n\nJusqu&#039;à ce que cela rende votre pipeline d&#039;analyse complètement aveugle.\n\nSi vous êtes récemment passé à **PHPUnit 13** et que votre analyse statique a soudainement déraillé avec des erreurs fantômes du type `unknown class PHPUnit\\Framework\\TestCase`, vous vous êtes heurté à un mur d&#039;isolation classique. Voyons pourquoi cela ne fonctionne plus et comment corriger le tir proprement.\n\n---\n\n## Le Symptôme\n\nVotre suite de tests s&#039;exécute dans Docker. Tout passe haut la main. Chaque assertion est au vert.\nPourtant, dès que vous lancez PHPStan, votre terminal explose :\n\n```text\n ------ ---------------------------------------------------------------------------- \n  Line   tests/Client/FakeClientTest.php                                             \n ------ ---------------------------------------------------------------------------- \n  12     Class App\\Tests\\Client\\FakeClientTest extends unknown class                 \n         PHPUnit\\Framework\\TestCase.                                                 \n         💡 Learn more at https://phpstan.org/user-guide/discovering-symbols         \n  30     Call to an undefined static method                                          \n         App\\Tests\\Client\\FakeClientTest::assertInstanceOf().                        \n ------ ---------------------------------------------------------------------------- \n```\n\nVous jetez un œil à votre fichier `phpstan.neon.dist`. Vous avez pourtant déjà fait le pont en indiquant à PHPStan où trouver l&#039;autoloader du projet :\n\n```yaml\nparameters:\n    level: max\n    paths:\n        - src/\n        - tests/\n    bootstrapFiles:\n        - vendor/autoload.php\n```\n\nVous double-vérifiez même l&#039;autoloader manuellement via PHP :\n\n```bash\nphp -r &quot;require &#039;vendor/autoload.php&#039;; echo class_exists(&#039;PHPUnit\\Framework\\TestCase&#039;) ? &#039;🟢 OUI&#039; : &#039;🔴 NON&#039;;&quot;\n```\n\nLa console renvoie `🟢 OUI`. La classe est bien là. Alors, pourquoi PHPStan est-il aveugle ?\n\n---\n\n## Pourquoi cela fonctionnait-il très bien avec PHPUnit 10 ?\n\nSi vous avez exactement cette même configuration sur un projet plus ancien tournant sous PHPUnit 9.5, tout fonctionne sans accroc. Qu&#039;est-ce qui a changé ?\n\n### 1. Le virage architectural de PHPUnit 10+\n\nDans PHPUnit 9, `TestCase` était une classe plutôt monolithique. Le moteur de réflexion statique de PHPStan (`BetterReflection`) n&#039;avait aucun mal à la cartographier depuis un répertoire externe.\n\nAvec PHPUnit 10 (et jusqu&#039;à la v13), le framework a été entièrement refactorisé. `TestCase` s&#039;appuie désormais sur un réseau complexe d&#039;interfaces et de traits internes. Lorsque PHPStan tente de l&#039;inspecter à distance au-delà des frontières du répertoire via un autoloader bootstrappé, le moteur de réflexion se perd dans l&#039;arbre d&#039;héritage et considère prudemment que la classe n&#039;existe pas.\n\n### 2. Le piège de l&#039;extension obsolète\n\nSi vous regardez le fichier `.tools/phpstan/composer.json` de votre outil isolé, vous y trouverez probablement une contrainte héritée d&#039;un ancien boilerplate de projet :\n\n```json\n&quot;require&quot;: {\n    &quot;phpstan/phpstan&quot;: &quot;*&quot;,\n    &quot;phpstan/phpstan-phpunit&quot;: &quot;^1.1&quot;\n}\n```\n\nCette contrainte `^1.1` verrouille l&#039;extension PHPUnit sur sa **branche 1.x**, historiquement conçue pour PHPUnit 9. L&#039;extension étant bloquée en v1.x, Composer fige silencieusement le cœur de `phpstan/phpstan` dans une version obsolète elle aussi (comme la `1.12.x`), ignorant complètement votre wildcard `*`. Vous vous retrouvez concrètement à analyser du code moderne sous PHPUnit 13 avec un moteur daté.\n\n---\n\n## La solution propre : abandonner les contraintes obsolètes\n\nPlutôt que de vous battre avec les chemins via `scanDirectories` ou d&#039;installer une copie factice de PHPUnit dans le répertoire de vos outils, mettez simplement à jour votre chaîne d&#039;outils. **PHPStan 2.0** et son extension **phpstan-phpunit 2.0** gèrent nativement l&#039;architecture complexe du PHPUnit moderne.\n\n### 1. Passer à la v2\n\nOuvrez `.tools/phpstan/composer.json` et forcez la mise à jour :\n\n```json\n{\n    &quot;require&quot;: {\n        &quot;php&quot;: &quot;&gt;=8.4&quot;,\n        &quot;phpstan/phpstan&quot;: &quot;^2.0&quot;,\n        &quot;phpstan/phpstan-phpunit&quot;: &quot;^2.0&quot;\n    },\n    &quot;config&quot;: {\n        &quot;bin-dir&quot;: &quot;./&quot;,\n        &quot;sort-packages&quot;: true\n    }\n}\n```\n\n### 2. Rafraîchir l&#039;environnement\n\nLancez une mise à jour dans le répertoire de votre outil pour reconstruire le fichier lock :\n\n```bash\ncd .tools/phpstan &amp;&amp; composer update\n```\n\n### 3. Vider le cache et analyser\n\nAssurez-vous que votre fichier `phpstan.neon.dist` utilise bien la variable de chemin absolu pour cibler le répertoire vendor racine de manière sécurisée :\n\n```yaml\nparameters:\n    level: max\n    paths:\n        - src/\n        - tests/\n    bootstrapFiles:\n        - %currentWorkingDirectory%/vendor/autoload.php\n\nincludes:\n    - .tools/phpstan/vendor/phpstan/phpstan-phpunit/extension.neon\n    - .tools/phpstan/vendor/phpstan/phpstan-phpunit/rules.neon\n```\n\nSupprimez l&#039;ancien cache d&#039;analyse pour éviter les résultats obsolètes :\n\n```bash\n.tools/phpstan/phpstan clear-result-cache\n```\n\nRelancez votre analyseur. Les erreurs fantômes vont disparaître et vous retrouverez vos indicateurs au vert, sans pour autant dégrader l&#039;architecture isolée de vos outils."
      }
    },
    {
      "type": "blog",
      "id": "fr/blog/2026/ubuntu-25-10-docker-java-cgroupv2-crash",
      "url": "https://ktherage.github.io/fr/blog/2026/ubuntu-25-10-docker-java-cgroupv2-crash/",
      "attributes": {
        "alias": "/blog/ubuntu-25-10-docker-java-cgroupv2-crash/",
        "title": "Ubuntu 25.10 : La mise à jour qui a brické mes conteneurs Docker Java",
        "date": "2026-04-28T00:00:00+00:00",
        "description": "Comment une simple mise à jour Ubuntu a fait planter mes conteneurs Selenium, et pourquoi le NullPointerException de cgroupv2 est le symptôme d'un problème de compatibilité entre le noyau Linux et les anciennes versions de Java.",
        "cover": {"image":"img/pexels-docker-port.jpg","alt":"Container cranes at a bustling port during sunset","caption":"Photo by <a href=\"https://www.pexels.com/@thorl5/\">thorl5</a> on <a href=\"https://www.pexels.com\">Pexels</a>"},
        "published": true,
        "updated": "2026-06-18T00:00:00+00:00",
        "tags": ["Ubuntu","Docker","cgroupv2","Debug"],
        "excerpt": "Après la mise à jour de mon système d'exploitaion d'Ubuntu 24.04 vers 25.10, mon conteneur Docker selenium/standalone-chrome s'est mis à planter avec un NullPointerException incompréhensible. Voici mon histoire.",
        "body": "## La mise à jour tranquille qui tourne au cauchemar\n\nC&#039;était un vendredi soir, comme les autres. Je décide enfin de faire ce que tout bon développeur évite : **mettre à jour son OS**. Ubuntu 24.04 LTS → 25.10, la petite mise à jour de routine. *&quot;Ça ne peut que s&#039;améliorer&quot;*, me dis-je avec ce pessimisme propre à ceux qui ont vu trop de mises à jour mal se passer (Ubuntu 24.10 je te vois !).\n\nJe lance la mise à jour en confiance. Tout se passe bien. Redémarrage. Tout fonctionne. Nickel.\n\nSauf que le lundi suivant, mes tests E2E ne passent plus. Le conteneur Docker `selenium/standalone-chrome:4.5.3` qui fonctionnait parfaitement la veille refuse désormais de démarrer. Il plante en boucle avec ce magnifique message d&#039;erreur :\n\n```\nchrome-1  | java.lang.reflect.InvocationTargetException\nchrome-1  |     at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke0(Native Method)\nchrome-1  |     at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62)\nchrome-1  |     at java.base/jdk.internal.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)\nchrome-1  |     at java.base/java.lang.reflect.Method.invoke(Method.java:566)\nchrome-1  |     at org.openqa.selenium.grid.Bootstrap.runMain(Bootstrap.java:77)\nchrome-1  |     at org.openqa.selenium.grid.Bootstrap.main(Bootstrap.java:70)\nchrome-1  | Caused by: java.lang.NullPointerException\nchrome-1  |     at java.base/jdk.internal.platform.cgroupv2.CgroupV2Subsystem.getInstance(CgroupV2Subsystem.java:81)\nchrome-1  |     at java.base/jdk.internal.platform.CgroupSubsystemFactory.create(CgroupSubsystemFactory.java:113)\nchrome-1  |     at java.base/jdk.internal.platform.CgroupMetrics.getInstance(CgroupMetrics.java:167)\nchrome-1  |     at java.base/jdk.internal.platform.SystemMetrics.instance(SystemMetrics.java:29)\nchrome-1  |     at java.base/jdk.internal.platform.Metrics.systemMetrics(Metrics.java:58)\nchrome-1  |     at java.base/jdk.internal.platform.Container.metrics(Container.java:43)\nchrome-1  |     at jdk.management/com.sun.management.internal.OperatingSystemImpl.&lt;init&gt;(OperatingSystemImpl.java:182)\nchrome-1  |     at jdk.management/com.sun.management.internal.PlatformMBeanProviderImpl.getOperatingSystemMXBean(PlatformMBeanProviderImpl.java:281)\nchrome-1  |     at jdk.management/com.sun.management.internal.PlatformMBeanProviderImpl$3.nameToMBeanMap(PlatformMBeanProviderImpl.java:198)\nchrome-1  |     at java.management/java.lang.management.ManagementFactory.lambda$getPlatformMBeanServer$0(ManagementFactory.java:487)\nchrome-1  |     at java.base/java.util.stream.ReferencePipeline$7$1.accept(ReferencePipeline.java:271)\nchrome-1  |     at java.base/java.util.stream.ReferencePipeline$2$1.accept(ReferencePipeline.java:177)\nchrome-1  |     at java.base/java.util.HashMap$ValueSpliterator.forEachRemaining(HashMap.java:1693)\nchrome-1  |     at java.base/java.util.stream.AbstractPipeline.copyInto(AbstractPipeline.java:484)\nchrome-1  |     at java.base/java.util.stream.AbstractPipeline.wrapAndCopyInto(AbstractPipeline.java:474)\nchrome-1  |     at java.base/java.util.stream.ForEachOps$ForEachOp.evaluateSequential(ForEachOps.java:150)\nchrome-1  |     at java.base/java.util.stream.ForEachOps$ForEachOp$OfRef.evaluateSequential(ForEachOps.java:173)\nchrome-1  |     at java.base/java.util.stream.AbstractPipeline.evaluate(AbstractPipeline.java:234)\nchrome-1  |     at java.base/java.util.stream.ReferencePipeline.forEach(ReferencePipeline.java:497)\nchrome-1  |     at java.management/java.lang.management.ManagementFactory.getPlatformMBeanServer(ManagementFactory.java:488)\nchrome-1  |     at org.openqa.selenium.grid.jmx.JMXHelper.register(JMXHelper.java:29)\nchrome-1  |     at org.openqa.selenium.grid.server.BaseServerOptions.&lt;init&gt;(BaseServerOptions.java:48)\nchrome-1  |     at org.openqa.selenium.grid.commands.Standalone.createHandlers(Standalone.java:124)\nchrome-1  |     at org.openqa.selenium.grid.TemplateGridServerCommand.asServer(TemplateGridServerCommand.java:41)\nchrome-1  |     at org.openqa.selenium.grid.commands.Standalone.execute(Standalone.java:245)\nchrome-1  |     at org.openqa.selenium.grid.TemplateGridCommand.lambda$configure$4(TemplateGridCommand.java:129)\nchrome-1  |     at org.openqa.selenium.grid.Main.launch(Main.java:83)\nchrome-1  |     at org.openqa.selenium.grid.Main.go(Main.java:57)\nchrome-1  |     at org.openqa.selenium.grid.Main.main(Main.java:42)\nchrome-1  |     ... 6 more\n```\n\n**Ma première pensée :**\n&lt;div class=&quot;d-flex flex-row m-2 justify-content-center&quot;&gt;\n  &lt;iframe src=&quot;https://giphy.com/embed/4ZxicT7ZQYcLShHOiz&quot; width=&quot;480&quot; height=&quot;274&quot; style=&quot;&quot; frameBorder=&quot;0&quot; class=&quot;giphy-embed&quot; allowFullScreen&gt;&lt;/iframe&gt;\n&lt;/div&gt;\n\n&gt; Je suis développeur PHP, pas Java.\n\n**Mon reflexe:**\n&lt;div class=&quot;d-flex flex-row m-2 justify-content-center&quot;&gt;\n  &lt;iframe src=&quot;https://giphy.com/embed/pUVOeIagS1rrqsYQJe&quot; width=&quot;480&quot; height=&quot;288&quot; style=&quot;&quot; frameBorder=&quot;0&quot; class=&quot;giphy-embed&quot; allowFullScreen&gt;&lt;/iframe&gt;\n&lt;/div&gt;\n\n&gt; Demandons à plus fort que soi. Gemini cricket (🤖🦗) 🥲.\n\n## L&#039;Enquête\n\nMon ami le cricket met le doigt sur **cgroupv2** dans la ligne `CgroupV2Subsystem.java:81` et fait le lien avec la mise à jour de mon système. Mais **cgroupv2**, c&#039;est quoi ?\n\n🤖🦗:\n&gt; Pour faire court, c&#039;est ce qui permet à Docker 🐋 de limiter le CPU ou la RAM d&#039;un container.\n\nConcrètement :\n* Docker dit à la JVM : *&quot;Tu as le droit à 2Go de RAM&quot;*.\n* Java lit ces infos dans les **cgroups** (fichiers de gestion des ressources).\n* Java ajuste son comportement (mémoire Heap, etc.) en conséquence.\n\n**C&#039;est censé être une bonne chose.** Cela évite que Java ne se fasse abattre par le *OOM Killer* du système hôte. Mais Java doit parser ces fichiers, et c&#039;est là que le bât blesse.\n\n&gt; **Pourquoi un crash et pas juste une erreur ?**\n&gt; Dans le code source des anciennes JVM, si le chemin retourné par l&#039;interface système n&#039;est pas exactement celui attendu, la variable `mountPoint` reste à `null`. La JVM tente ensuite d&#039;appeler une méthode sur cet objet inexistant. C&#039;est l&#039;arroseur arrosé : la fonction censée protéger votre application devient la cause de son exécution sommaire.\n\n## Le Plot Twist : La différence subtile entre Ubuntu 24.04 et 25.10\n\nC&#039;est ici que l&#039;affaire devient fascinante.\n\nLe véritable coupable, c&#039;est l&#039;évolution de systemd (passé en version 258 sur la version d&#039;Ubuntu 25.10). Depuis la v256, systemd impose un &#039;durcissement&#039; de la hiérarchie cgroup v2. Il ne se contente plus d&#039;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 &#039;aveugles&#039; face à cette nouvelle structure.\n\n**Et devinez quoi ?** La vieille logique d&#039;initialisation de Java (avant Java 17) est beaucoup trop rigide pour comprendre ce nouveau format.\n\nAu démarrage, le Java de notre conteneur fouille dans le système, ne trouve pas le contrôleur &quot;memory&quot; exactement là où il l&#039;attendait, et assigne silencieusement null à sa variable interne. À la ligne suivante, le code tente d&#039;appeler la méthode .getMountPoint() sur cet objet vide.\n\n**BOOM**. NullPointerException. Mort instantanée du processus.\n\nLe coupable n&#039;était ni notre code, ni notre configuration Docker, mais une ancienne JVM incapable de s&#039;adapter à la nouvelle hiérarchie d&#039;un noyau Linux moderne. Normale aussi vous me direz.\n\n## LA Solution\n\nLa solution propre, celle qu&#039;on devrait toujours utiliser en production :\n\n```bash\n# Mettre à jour vers une version récente de l&#039;image\ndocker pull selenium/standalone-chrome:4.20.0\n\n# OU utiliser une image avec Java 17+\ndocker pull selenium/standalone-chrome:latest\n```\n\n## Le Hack de survie — Désactiver UseContainerSupport\n\nSi vous ne pouvez pas mettre à jour l&#039;image (contraintes legacy, validation QA, etc.), vous pouvez désactiver la détection de conteneur :\n\n```bash\n# Option 1 : Via variable d&#039;environnement Docker\ndocker run -d \\\n  -e JAVA_OPTS=&quot;-XX:-UseContainerSupport&quot; \\\n  selenium/standalone-chrome:4.5.3\n\n# Option 2 : Via docker-compose.yml\nservices:\n  chrome:\n    image: selenium/standalone-chrome:4.5.3\n    environment:\n      - JAVA_OPTS=-XX:-UseContainerSupport\n```\n\n:::warning\n**Attention:** Si vous cummulez plusieurs options, pensez a les séparer par des espaces. Exemple : `JAVA_OPTS=&quot;SOME_EXISTING_OPTIONS -XX:-UseContainerSupport`\n:::\n\n:::caution\n**⚠️ Avertissement important :**\n\n- **NE FAITES PAS ÇA EN PRODUCTION.**, dans mon cas il s&#039;agit d&#039;un container de **developpement** en locale.\n- Sans `UseContainerSupport`, Java ne connaît pas ses limites et peut se faire killer par le OOM Killer du système hôte.\n- Cette solution est un **pansement temporaire** en attendant la mise à jour.\n:::\n\n## La morale de cette histoire\n\nNos écosystèmes logiciels sont **fragiles**. Une simple mise à jour du noyau Linux — via une mise à jour d&#039;Ubuntu dans mon cas — peut casser des conteneurs qui fonctionnaient parfaitement d&#039;une version à l&#039;autre.\n\n**Les conseils philosophiques :**\n\n&gt; *Penser à nettoyer sa chambre régulièrement (je suis sûr que vous comprenez l&#039;image) peut faire gagner beaucoup de temps.*\n\n&gt; *Ne faites jamais de mise à jour d&#039;OS un vendredi.*\n\n&gt; *Prenez soin de toujours tester vos conteneurs Docker sur un environnement de staging après une mise à jour système.*\n\n&gt; *&quot;Works on my machine&quot; — jusqu&#039;à la mise à jour du kernel.*\n\n\n## Sources et Références\n\n- [Oracle Docs : Java Container Support](https://docs.oracle.com/en/java/javase/17+containers/) \n- [Ubuntu 25.10 Release Notes](https://ubuntu.com/blog/ubuntu-25-10)\n- [Docker &amp; Java : Best Practices](https://docker-java.readthedocs.io/)\n- [cgroup v2 kernel documentation](https://www.kernel.org/doc/Documentation/cgroup-v2.txt)\n- [Systemd News : Changes in unified cgroup hierarchy handling (v256+)](https://systemd.io/)"
      }
    },
    {
      "type": "blog",
      "id": "fr/blog/2026/my-eventsubscriber-silenced-errors",
      "url": "https://ktherage.github.io/fr/blog/2026/my-eventsubscriber-silenced-errors/",
      "attributes": {
        "alias": "/blog/my-eventsubscriber-silenced-errors/",
        "title": "Mon EventSubscriber masquait les erreurs, voici pourquoi",
        "date": "2026-04-13T00:00:00+00:00",
        "description": "Comment mon EventSubscriber de liste blanche de routes cachait de véritables erreurs et comment je l'ai corrigé.",
        "cover": {"image":"img/terminal-code.jpg","alt":"Texte de langage de programmation informatique","caption":"Photo par <a href=\"https://www.pexels.com/@nathan-dumlao/\">Nathan Dumlao</a> sur <a href=\"https://www.pexels.com\">Pexels</a>"},
        "published": true,
        "tags": ["Symfony","Debug","Security"],
        "excerpt": "Mon EventSubscriber de liste blanche de routes levait des exceptions AccessDenied dans les logs sans raison apparente. Voici comment j'ai découvert qu'il cachait en réalité la véritable erreur en coulisses.",
        "body": "Un ticket Jira est apparu : *« Il y a un bug étrange qui empêche les utilisateurs d&#039;accéder à une page à ce moment-là de la journée. »*\nLes logs indiquaient plusieurs fois : *« [ce jour T cette heure] request.ERROR: Uncaught PHP Exception Symfony\\Component\\HttpKernel\\Exception\\AccessDeniedHttpException: &quot;Access denied to that resource.&quot; at WhitelistSubscriber.php line 99 »*\n\nJe n&#039;avais aucune idée au début… 😅 Voici comment j&#039;ai compris.\n\n---\n\n## La configuration\n\nJ&#039;avais un EventSubscriber qui vérifiait l&#039;accès aux pages basé sur une liste blanche de routes. C&#039;était du code legacy — le refactoriser n&#039;était pas à l&#039;ordre du jour à ce moment-là.\n\n```php\n&lt;?php\n\nnamespace App\\EventSubscriber;\n\nuse Symfony\\Component\\EventDispatcher\\EventSubscriberInterface;\nuse Symfony\\Component\\HttpKernel\\Event\\RequestEvent;\nuse Symfony\\Component\\HttpKernel\\KernelEvents;\n\nclass WhitelistRouteSubscriber implements EventSubscriberInterface\n{\n    private const WHITELISTED_ROUTES = [\n        &#039;app_login&#039;,\n        &#039;app_homepage&#039;,\n        &#039;app_healthcheck&#039;,\n    ];\n\n    public static function getSubscribedEvents(): array\n    {\n        return [\n            KernelEvents::REQUEST =&gt; [&#039;onKernelRequest&#039;, 0],\n        ];\n    }\n\n    public function onKernelRequest(RequestEvent $event): void\n    {\n        $request = $event-&gt;getRequest();\n        $route = $request-&gt;attributes-&gt;get(&#039;_route&#039;);\n\n        // Allow whitelisted routes\n        if (in_array($route, self::WHITELISTED_ROUTES, true)) {\n            return;\n        }\n\n        // Deny access for non-whitelisted routes\n        throw new AccessDeniedHttpException(&#039;Route not whitelisted&#039;);\n    }\n}\n```\n\nObjectif : Bloquer toutes les routes sauf la liste blanche. Simple, non ?\n\n---\n\n## Le problème\n\nLes logs montraient des `AccessDeniedHttpException` sur des routes que je savais être dans la liste blanche. Premier geste classique : mettre un `dump()` dans le subscriber pour voir ce qui arrivait.\n\n```php\npublic function onKernelRequest(RequestEvent $event): void\n{\n    $request = $event-&gt;getRequest();\n    $route = $request-&gt;attributes-&gt;get(&#039;_route&#039;);\n\n    dump($route); // 🔍 Voyons ce qui se passe\n    // ...\n}\n```\n\nPremière découverte surprenante : **le subscriber était appelé deux fois** pour une seule requête. Le premier appel avait la route attendue, le second avait `$route = null`.\n\nQuestion évidente : *pourquoi `_route` est-il null ?*\n\nJ&#039;ai creusé plus loin avec `dump($request-&gt;getPathInfo())` pour voir quelle URL était traitée lors du second appel :\n\n```\n// 1er appel\ndump($request-&gt;getPathInfo()); // &quot;/foo&quot;\n\n// 2e appel\ndump($request-&gt;getPathInfo()); // &quot;/foo&quot; ← identique. Attends, quoi ?\n```\n\nMême URL, appelée deux fois. Cela n&#039;avait aucun sens — si c&#039;était la même requête, pourquoi `_route` était-il null la deuxième fois ? Je tournais en rond.\n\nJ&#039;ai donc dumpé l&#039;objet `$event` complet pour avoir plus de contexte, et j&#039;ai réduit le champ à `_controller` dans les attributs de la requête :\n\n```php\ndump($request-&gt;attributes-&gt;get(&#039;_controller&#039;));\n// &quot;Symfony\\Component\\HttpKernel\\Controller\\ErrorController&quot;\n```\n\nVoilà. `_controller` ne pointait pas vers mon code du tout. Symfony avait forgé une toute nouvelle requête vers son propre `ErrorController`, réutilisant l&#039;URL d&#039;origine — ce qui explique pourquoi `getPathInfo()` était si trompeur — mais en contournant complètement le routeur. C&#039;est pourquoi `_route` était null.\n\n---\n\n## Cause racine\n\nLe flux réel était :\n\n```\nRequête → /foo\n  └── WhitelistSubscriber (1er appel) → _route = &#039;app_foo&#039; ✅ Accès accordé\n      └── Controller → lève RealException 💥\n          └── Symfony l&#039;attrape\n              └── Sous-requête → ErrorController (contourne le routeur, pas de _route)\n                  └── WhitelistSubscriber (2e appel) → _route = null ❌ AccessDenied levé\n                      └── RealException est maintenant silencieuse 🔇\n```\n\nLe piège : **l&#039;`AccessDeniedHttpException` du subscriber masquait complètement l&#039;exception originale** — celle qui contenait réellement les informations de débogage utiles.\n\nLorsqu&#039;une exception est levée, le `HttpKernel` de Symfony distribue un événement `KernelEvents::EXCEPTION`, puis délègue le rendu de l&#039;erreur à `ErrorController` via une sous-requête interne. Cette sous-requête réutilise l&#039;URL d&#039;origine — ce qui explique pourquoi `getPathInfo()` était trompeur — mais elle contourne complètement la couche de routage, laissant `_route` à `null`.\n\n---\n\n## La solution\n\nVérifiez si la requête est la requête principale (pas une sous-requête) :\n\n```php\n&lt;?php\n\nnamespace App\\EventSubscriber;\n\nuse Symfony\\Component\\EventDispatcher\\EventSubscriberInterface;\nuse Symfony\\Component\\HttpKernel\\Event\\RequestEvent;\nuse Symfony\\Component\\HttpKernel\\KernelEvents;\n\nclass WhitelistRouteSubscriber implements EventSubscriberInterface\n{\n    private const WHITELISTED_ROUTES = [\n        &#039;app_login&#039;,\n        &#039;app_homepage&#039;,\n        &#039;app_healthcheck&#039;,\n    ];\n\n    public static function getSubscribedEvents(): array\n    {\n        return [\n            KernelEvents::REQUEST =&gt; [&#039;onKernelRequest&#039;, 0],\n        ];\n    }\n\n    public function onKernelRequest(RequestEvent $event): void\n    {\n        // Skip sub-requests (like error handling)\n        if (!$event-&gt;isMainRequest()) {\n            return;\n        }\n\n        $request = $event-&gt;getRequest();\n        $route = $request-&gt;attributes-&gt;get(&#039;_route&#039;);\n\n        // Allow whitelisted routes\n        if (in_array($route, self::WHITELISTED_ROUTES, true)) {\n            return;\n        }\n\n        // Deny access for non-whitelisted routes\n        throw new AccessDeniedHttpException(&#039;Route not whitelisted&#039;);\n    }\n}\n```\n\n`isMainRequest()` retourne `false` pour toute sous-requête interne — gestion d&#039;erreurs, fragments ESI, `hinclude` — donc votre logique ne s&#039;exécute que sur les vraies requêtes distribuées par le routeur.\n\n&gt; **Note :** `isMainRequest()` a remplacé l&#039;ancien `isMasterRequest()` dans Symfony 5.3. Si vous êtes sur une version plus ancienne, utilisez `isMasterRequest()` à la place.\n\n---\n\n## Conclusion\n\nChaque fois que votre subscriber fait quelque chose de destructeur — lever une exception, rediriger, définir une réponse — demandez-vous : *que se passe-t-il quand Symfony appelle ceci sur une sous-requête ?*\n\nLes sous-requêtes sont omniprésentes dans Symfony : gestion d&#039;erreurs, ESI, fragments. Elles n&#039;ont pas le même contexte qu&#039;une requête principale, et votre subscriber ne connaît pas la différence à moins que vous ne lui disiez.\n\n`isMainRequest()` est cette vérification. Faites-en un réflexe. 🎉"
      }
    },
    {
      "type": "blog",
      "id": "fr/blog/2026/gitignore-blacklisting-whitelisting",
      "url": "https://ktherage.github.io/fr/blog/2026/gitignore-blacklisting-whitelisting/",
      "attributes": {
        "alias": "/blog/gitignore-blacklisting-whitelisting/",
        "title": "Déboguer le .gitignore de Git : Pourquoi la liste blanche dans les sous-répertoires échoue",
        "date": "2026-03-25T00:00:00+00:00",
        "description": "Une plongée dans les règles de traversée des répertoires du .gitignore et comment éviter les pièges courants lors de la mise en liste blanche de fichiers dans les sous-répertoires.",
        "cover": {"image":"img/pexels-photo-577585.jpeg","alt":"Image de www.pexels.com - Crédits Kevin Ku","caption":"Image de <a href=\"https://www.pexels.com\">www.pexels.com</a> - Crédits <a href=\"https://www.pexels.com/@kevin-ku-92347/\">Kevin Ku</a>"},
        "published": true,
        "tags": ["Git"],
        "excerpt": "En utilisant .gitignore pour garder votre projet propre, il est facile de cacher accidentellement des fichiers importants dans les sous-répertoires. Voici comment déboguer et résoudre ce problème courant.",
        "body": "## Introduction\n\nLorsque vous travaillez avec Git, il est courant d&#039;utiliser `.gitignore` 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&#039;il s&#039;agit de répertoires imbriqués. Dans cet article, je vais vous présenter un exemple concret d&#039;une règle `.gitignore` destinée à garder un projet propre qui a fini par cacher des fichiers importants, et comment nous l&#039;avons corrigée.\n\n---\n\n## La configuration\n\nJe voulais garder le répertoire `.tools/` propre, en ne suivant que `composer.json`, `composer.lock` et le fichier `.gitignore` lui-même. Mon `.tools/.gitignore` initial ressemblait à ceci :\n\n```gitignore\n*\n!.gitignore\n!composer.json\n!composer.lock\n```\n\nObjectif : Ne suivre que composer.json, composer.lock et .gitignore dans .tools/ et ses sous-répertoires, en ignorant tout le reste.\n\n---\n\n## Le problème\n\nAprès avoir poussé ce changement, un collègue a signalé que son fichier composer.lock dans `.tools/rector/` était ignoré. Nous avons utilisé la commande suivante pour déboguer :\n\n```bash\n$ git check-ignore -v .tools/rector/composer.lock\n.tools/.gitignore:1:*     .tools/rector/composer.lock\n```\n\n---\n\n## Cause racine\n\nLa règle de Git : *« Il n&#039;est pas possible de ré-inclure un fichier si un répertoire parent de ce fichier est exclu. »*\n\nLe motif `*` ignore à la fois les fichiers et les répertoires, ce qui signifie que Git ne regarde jamais à l&#039;intérieur de `.tools/rector/` — donc les règles de liste blanche pour `composer.json` et `composer.lock` ne s&#039;appliquent jamais.\n\n---\n\n## La solution\n\nAprès débogage, nous avons mis à jour le `.gitignore` pour permettre explicitement la traversée des répertoires et ré-inclure les fichiers nécessaires :\n\n```gitignore\n# Ignore all files and directories at this level\n*\n\n# But allow Git to inspect subdirectories\n!*/\n\n# Explicitly ignore vendor directories\nvendor\n\n# Whitelist composer.json in any subdirectory\n!*/composer.json\n\n# Whitelist composer.lock in any subdirectory\n!*/composer.lock\n\n# Always keep this .gitignore file\n!.gitignore\n```\n\n---\n\n## Points clés à retenir\n\n| Répertoire/Fichier | Règle appliquée | Résultat |\n|---------------------|-----------------|----------|\n| .tools/ | `*` | Ignoré |\n| .tools/rector/ | `!*/` | Inspecté |\n| .tools/rector/vendor | `vendor` | Ignoré |\n| .tools/rector/composer.json | `!*/composer.json` | Suivi |\n\n- **La traversée des répertoires par Git :** Lorsque vous utilisez `*` pour tout ignorer, Git ne regardera pas à l&#039;intérieur des répertoires sauf si vous l&#039;autorisez explicitement avec `!*/`.\n- **Tester vos règles :** Testez toujours votre `.gitignore` avec `git check-ignore -v &lt;fichier&gt;` et `git status` pour vous assurer que les fichiers attendus sont suivis.\n- **L&#039;ordre a son importance :** Placez les exclusions générales en premier, puis ré-incluez les fichiers ou répertoires spécifiques.\n- **Pièges courants :** N&#039;oubliez pas de ré-exclure les répertoires comme `vendor` après la mise en liste blanche, sinon ils seront inclus dans votre dépôt.\n\n---\n\n## Conclusion\n\nDéboguer les problèmes de `.gitignore` 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&#039;hésitez pas à utiliser `git check-ignore` pour vérifier votre configuration."
      }
    }
  ]
}