{
  "data": [
    {
      "type": "blog",
      "id": "blog/2026/building-a-rag-powered-code-review-assistant",
      "url": "https://ktherage.github.io/blog/2026/building-a-rag-powered-code-review-assistant/",
      "attributes": {
        "alias": "/blog/building-a-rag-powered-code-review-assistant/",
        "title": "Building a RAG-Powered Code Review Assistant with PHP, Ollama, and Qdrant",
        "date": "2026-08-10T00:00:00+00:00",
        "description": "How I turned 20 years of Symfony code reviews into a local AI assistant with PHP, Ollama, and Qdrant — no GPU required.",
        "cover": {"image":"img/pexels-cottonbro-6153344.jpg","alt":"Close-up of a human fist punching a prosthetic hand, symbolizing technology and human connection.","caption":"Photo by <a href=\"https://www.pexels.com/@cottonbro/\">cottonbro studio</a> on <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": "Ask an LLM to review your code and you'll get generic advice. Give it 10,000 Symfony Core code reviews as reference — and it'll produce reviews that sound like nicolas-grekas and stof reviewed your PR. Here's how I built a RAG pipeline in 100% PHP to make that happen.",
        "body": "[LLMs](https://en.wikipedia.org/wiki/Large_language_model) are great at producing code reviews that sound right. But &quot;sounding right&quot; isn&#039;t the same as being useful. A review that tells you to &quot;fix the Code Style&quot; is correct but useless — every project applies a Code Style that may differ.\n\nSymfony has more than **20 years of public code reviews** on [GitHub](https://github.com/). Every merged PR contains comments from [nicolas-grekas](https://github.com/nicolas-grekas), [stof](https://github.com/stof), [dunglas](https://github.com/dunglas), [xabbuh](https://github.com/xabbuh), and dozens of other reviewers from the core team and contributors. It&#039;s a goldmine of domain-specific review patterns: which arguments convince, which patterns get rejected, what the community considers good Symfony code.\n\nThe problem? No one had built a search engine to exploit it. So I did it 🤣.\n\n## The lore behind this crazy idea\n\nThis story starts at the [Symfony Live in Paris](https://live.symfony.com/). As you can imagine, this year&#039;s edition was very AI-focused. I saw quite a few talks about it and wanted to play around with this novelty, but until then I didn&#039;t have a concrete use case.\n\nI attended a talk by [Grégoire Pineau](https://github.com/lyrixx) where he explained how, with [Symfony AI](https://ai.symfony.com/), [Clickhouse](https://clickhouse.com/) and [redirection.io](https://redirection.io/), he had successfully migrated an e-commerce site while reducing traffic loss.\n\nLater, the [Console Bundle](https://symfony.com/blog/new-in-symfony-8-1-http-less-symfony-applications) arrived.\n\nI had already asked an LLM to review the changes I had made _(on personal projects of course)_ and, as you might guess, I got advice like `consider using dependency injection`, `maybe extract this logic into a service` or `remember to check the code style`. These pieces of feedback are technically correct, but above all universally applicable and completely generic. In short, nothing that can&#039;t be fixed with good tools and a little rigor.\n\nComing out of Symfony Live, a somewhat crazy idea came to me.\nWhat if I could ask the same LLM: \n&gt; &quot;Review this code the way stof would&quot;\n\nI&#039;d get ultra-sharp feedback and code that would come out stronger.\n\nOr\n\n&gt; &quot;Review this code the way any Symfony contributor would&quot;\n\nThen I&#039;d get the whole community&#039;s point of view on the code I just created. \n\nOr even\n\n&gt; &quot;Review this code the way any member of the Symfony core team would&quot;\n\nI&#039;d have a panel of experts at my disposal to explain what&#039;s wrong with what I did.\n\nThat&#039;s what **Symfony Reviewer MCP** does: a [semantic search engine (RAG)](https://en.wikipedia.org/wiki/Retrieval-augmented_generation) over all of Symfony&#039;s historical code reviews, accessible via the [GitHub](https://docs.github.com/en/rest) API, exposed through the [Model Context Protocol (MCP)](https://en.wikipedia.org/wiki/Model_Context_Protocol), all with [Symfony AI](https://ai.symfony.com/) and in a [Symfony HTTP-less](https://symfony.com/blog/new-in-symfony-8-1-http-less-symfony-applications) application.\n\nI built it in [PHP 8.5](https://www.php.net/releases/8.5/) with [Symfony 8.1](https://symfony.com/), using [Ollama](https://ollama.com/) locally for vectorization with a model from [huggingface.co](https://huggingface.co/) (embeddinggemma-300m, 768 dimensions) and [Qdrant](https://qdrant.tech/) as the vector database.\n\nNo GPU required — if you accept the trade-off: indexing all historical reviews ran on my machine&#039;s CPU _for several days_ 😅. For a one-off project, I found this trade-off largely acceptable and cost-efficient.\n\nHow does it work?\n\n## Global architecture\n\nThe global architecture is split into two big blocks:\n1. **RAG generation:** \n    1. Data fetching and caching\n    2. Dataset generation and indexing into [Qdrant](https://qdrant.tech/)\n2. **The MCP server**\n\n### RAG generation\n\nHere is the complete RAG generation pipeline:\n\nDon&#039;t worry if this diagram looks dense — I&#039;ll walk through every step of the pipeline in the rest of the article, from fetching GitHub reviews to semantic search.\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;GitHub API (symfony/symfony)&quot;] --&gt; B[&quot;PullsFetcher (merged PRs only)&quot;]\n    B --&gt; C[&quot;ReviewsFetcher (comments + replies)&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#### Fetching the data\n\nBefore all this HTTP decorator machinery matters, you first have to walk the [GitHub API](https://docs.github.com/en/rest) and decide what&#039;s worth keeping.\n\n`PullsFetcher` pages through `GET /repos/symfony/symfony/pulls?state=all&amp;per_page=100`, keeping only PRs whose `merged_at` isn&#039;t null — no point training the system on rejected ideas. Rather than blindly paging until hitting an empty page, it first sends a single `HEAD` request and reads the total page count directly from the [Link header](https://docs.github.com/en/rest/using-the-rest-api/using-pagination-in-the-rest-api). That `HEAD` call is exactly why `BLACKLISTED_PATTERN` exists: it&#039;s a discovery request, not something worth caching for 365 days.\n\n`ReviewsFetcher` then walks [`GET /repos/symfony/symfony/pulls/{id}/comments`](https://docs.github.com/en/rest/pulls/comments) for each PR and rebuilds the actual conversation tree — parent comments with their replies attached. The catch: GitHub&#039;s API doesn&#039;t guarantee comment ordering. If a reply arrives before its parent, `ReviewsFetcher` parks it in a temporary pool (`$repliesTempPool`) instead of dropping it, and reattaches it as soon as the parent is found. A small piece of bookkeeping, but without it, any thread where three people argue about tabs vs. spaces out of order would silently lose replies.\n\n##### The HTTP decorator chain: Logging &amp; Caching\n\n###### The architecture\n\nBoth fetchers go through the same small [HTTP decorator chain](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 + Bearer auth)&quot;] --&gt; B[&quot;CachedHttpClient (filesystem cache, 365d TTL)&quot;]\n    B --&gt; C[&quot;LoggedHttpClient (structured logging)&quot;]\n    C --&gt; D[&quot;HttpClient::create() (Symfony native)&quot;]\n&lt;/pre&gt;\n\nEach decorator adds one responsibility:\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###### The serialization problem along the way\n\nThis chain contains a trap I already documented in my previous article — Symfony&#039;s [`HttpClient::getInfo()`](https://symfony.com/doc/current/http_client.html#information-related-to-the-response) contains a `pause_handler` key with a `Closure`, impossible to serialize. The `CachedResponse` class handles this by filtering out non-serializable values:\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\nWithout this filter, [`FilesystemAdapter`](https://symfony.com/doc/current/components/cache.html) silently fails — the serialization exception is caught by `DefaultMarshaller` with `throwOnSerializationFailure` set to `false`, and the cache key is quietly ignored.\n\n#### Dataset generation\n\nThe `BuildCommand` class orchestrates the vectorization pipeline:\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##### The base file\n\nOnce the data is fetched, `DatasetGenerator` transforms each PR and its reviews into a structured text file:\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\nThese files live in `var/dataset/pull-{id}.txt` and serve as the ground truth for vectorization.\n\n\n##### Qdrant integration\n\nThe vector store is wired into the container as `StoreInterface`, via Qdrant&#039;s `StoreFactory`:\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\nTwo details are worth mentioning. \n1. The `Vectorizer` uses exactly the same [Ollama](https://ollama.com/) model as at build time — an embedding model `hf.co/ggml-org/embeddinggemma-300m-qat-q8_0-GGUF:Q8_0` producing 768-dimension vectors.\nThis part is extremely important if you don&#039;t want to end up comparing apples with oranges during MCP search. Indeed, a vector generated with a specific model can&#039;t be compared with a vector generated with another model.\n2. The store reuses the `LoggedHttpClient` decorator, so every Qdrant round-trip benefits from structured logging on top of Symfony&#039;s native HTTP client.\n\n##### Vectorization &amp; storage\n\nThis is where things get hairy — this part alone took me days.\n\n###### What happens during vectorization?\n\nThe file from `var/dataset/pull-{id}.txt` is read, then sent to [Ollama](https://ollama.com/) to ask an embedding model — in my case `hf.co/ggml-org/embeddinggemma-300m-qat-q8_0-GGUF:Q8_0` — which generates a 768-dimension vector _(a number that depends on the embedding model)_ before it&#039;s sent back to symfony-ai by Ollama, to finally be saved into a vector space in [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(dataset file content)\n    Ollama-&gt;&gt;Model: model inference\n    Model--&gt;&gt;Ollama: 768 float values\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\nExample vector:\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... (line truncated to 2000 chars)\n```\n\n###### Why a sequential approach?\n\nAs explained in the intro, without a dedicated GPU, it&#039;s my CPU — more precisely the iGPU integrated into my CPU — that has to do the vectorization work.\nEven though the iGPU shares RAM with the CPU and even though 32 GB are available _(depending on my system usage, running programs, ...)_, RAM speed has nothing to do with graphics card memory, which is far faster and dedicated.\nMoreover, the CPU can only process a few operations in parallel, where the GPU executes thousands simultaneously, which makes matrix computations very slow and saturates my machine at 100%.\n\nAs a result, only one vectorization at a time is possible.\nAnd so yes, a batch upsert would be faster, but the goal was to build a working pipeline using only local resources.\n\nIt&#039;s not a limitation of PHP or Qdrant, just a pragmatic trade-off tied to the available hardware.\n\n###### File-rename atomicity\n\nThis is the most interesting design decision. Instead of a database table to track processed files, the `Builder` uses atomic `rename()` calls:\n\n```\npull-{id}.txt               → ready to process\nprocessing_pull-{id}.txt    → currently vectorizing\nragged_pull-{id}.txt        → vectorized successfully\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; // another process claimed it\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 by design: if the script dies mid-way, `recoverOrphanedProcessingFiles()` re-queues orphaned `processing_*` files on the next run. No locks, no database, no race conditions.\n\n\nYes, the code shows that, despite what I said above:\n\n&gt; As a result, only one vectorization at a time is possible.\n\nYes, I still tried 🤣.\n\n\n#### Usage\n\nThe CLI exposes two commands:\n\n```bash\n# Full pipeline: fetch → dataset → vectorize\nphp bin/console mcp:build\n\n# Re-vectorize without re-fetching\nphp bin/console mcp:build --skip-generation\n\n# Re-fetch without re-vectorizing\nphp bin/console mcp:build --skip-build\n```\n\n### The MCP server\n\nFor the LLM to have access to these freshly indexed reviews and run its searches by itself, you have to give it access.\nEverything happens through the MCP protocol following this simplified call pipeline:\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;MCP Tool Call (review_as_group/person)&quot;] --&gt; I[&quot;Retriever (semantic search)&quot;]\n    I --&gt; G[&quot;Qdrant (collection: reviews)&quot;]\n    I --&gt; J[&quot;LLM Client (Claude Desktop)&quot;]\n&lt;/pre&gt;\n\nThe generation pipeline (fetch → dataset → vectorize) and the serving pipeline (retrieve → respond) share a single point in common: the [Qdrant](https://qdrant.tech/) collection. \n\nThe MCP server exposes two **tools** and four **prompts**:\n\n| Tool | Role |\n|---|---|\n| `review_as_group` | Search by affiliation group (MEMBER, CONTRIBUTOR, NONE) |\n| `review_as_person` | Search by a specific reviewer (nicolas-grekas, stof, etc.) |\n\n| Prompt | Role |\n|---|---|\n| `review_as_group` | Formatted message using `review_as_group` |\n| `review_as_person` | Formatted message using `review_as_person` |\n| `get_stofed` | Forces the reviewer to &quot;stof&quot; — the most prolific reviewer of Symfony Core |\n| `hq_review` | Multi-review: queries 14 reviewers and synthesizes a markdown report |\n\n#### Retrieval: MCP Tools\n\nWhen a user sends a query through an MCP tool, here is what happens:\n1. The tool builds a query combining reviewer, file path, and diff\n2. `RetrieverInterface::retrieve()` vectorizes the query via Ollama\n3. A cosine similarity search runs on Qdrant\n4. The matching `VectorDocument` objects are returned\n5. Their `metadata[&#039;content&#039;]` is extracted and assembled into context\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 MCP Server (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: query vector\n    Retriever-&gt;&gt;Qdrant: cosine similarity search\n    Qdrant--&gt;&gt;Retriever: closest documents\n    Retriever--&gt;&gt;Tool: VectorDocument[]\n    Tool--&gt;&gt;MCP: found reviews (text)\n    MCP--&gt;&gt;Client: tool result\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#### The HQ Review prompt\n\nThe `hq_review` prompt is the showcase feature. It queries 14 top-tier Symfony reviewers ([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)), collects their historical feedback on the same file/diff, and asks the LLM to synthesize a markdown report with feedback weighted per reviewer.\n\nThe result is a code review that reads like a mini-symposium of Symfony Core maintainers — without requiring their time.\n\n#### Usage\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\nThen configure Claude Desktop (or any MCP client) by adding the server to `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## Environment variables\n\nAll the configuration goes through `.env` variables:\n\n| Variable | Role |\n|---|---|\n| `GITHUB_TOKEN` | [GitHub personal access token](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens) |\n| `QDRANT_DSN` | Qdrant service URL |\n| `OLLAMA_DSN` | Ollama service URL |\n| `BLACKLISTED_PATTERN` | JSON array of URL patterns to exclude from the cache |\n| `APP_VERSION` | Version displayed in the MCP metadata |\n\n## Lessons learned\n\nThis project taught me how the MCP protocol works, what a vector database is and how to use it. The architecture presented here is fairly standard for a RAG pipeline, but it was built with Symfony and can serve as a base for other experiments or use cases.\n\n### What worked well\n\n- **File-rename atomicity**: This pattern is elegant, crash-safe, and requires no infrastructure. Every PHP developer understands `rename()`. No Redis locks, no database migrations.\n- **Incremental pipeline**: Re-running `mcp:build` with existing files is a no-op. Iteration is fast — you can tweak the vectorization and only process new files.\n- **PHP 8.x features**: Constructor promotion, readonly properties, the pipe operator (`|&gt;`), and invokable commands make the code significantly cleaner.\n- **[Ollama](https://ollama.com/) locally**: embeddinggemma-300m runs on CPU without issues. 768 dimensions is modest enough for fast queries but rich enough for semantic search over code reviews.\n\n### What needs improvement\n\nHad I had a more powerful machine with a dedicated GPU or unified memory (👋 Mac owners), I might have been able to change the following:\n- **Naive Qdrant interaction**: Documents are added one at a time. A batch upsert would be significantly faster for large volumes.\n- **No incremental RAG updates**: The pipeline is add-only. There&#039;s no &quot;builtin&quot; mechanism _(it&#039;s possible via the Qdrant dashboard)_ to purge or update existing vectors when PR comments are edited on GitHub. Which is a real/false problem in itself, since you rarely find new comments on already-merged pull requests.\n- **Add your own conventions**: I added Symfony&#039;s reviews, but you too can modify and adapt the code to rely on an additional data corpus, like your colleagues&#039; reviews.\n\n### What I&#039;d do differently\n\n1. **Batch vectorization**: Group documents and vectorize in batches for higher throughput\n2. **Async fetching**: The data fetching phase is sequential per PR. Concurrent requests would significantly cut the initial build time.\n3. **GitHub webhook**: Instead of periodically rebuilding, listen for merged PR events and update the dataset incrementally\n4. **Embedding model evaluation**: 768 dimensions works well, but I should compare different models: smaller ones (like [all-MiniLM-L6-v2](https://huggingface.co/sentence-transformers/all-MiniLM-L6-v2), 384 dimensions) to improve performance, or much larger ones to assess the potential gain in search quality.\n5. **Wider context: PR comments and full diff**: the current dataset only keeps review comments attached to a specific diff. It ignores general PR comments (issue comments, description) and above all the PR&#039;s full diff — a reviewer never judges an isolated line, they judge it in the context of the whole change. Injecting both would give the model far more material to understand why a review was worded the way it was.\n6. **Exploit the `metadata` field for GitHub&#039;s raw JSON**: [`symfony/ai-store`](https://github.com/symfony/ai-store) attaches a `Metadata` object (`Symfony\\AI\\Store\\Document\\Metadata`) to every `VectorDocument`, which travels all the way to the chosen store. With Qdrant, this `Metadata` maps exactly to the notion of *payload*: an arbitrary JSON object attached to each point, natively indexable and filterable — for example filtering by `reviewer_association`, by date, or by reaction count, without re-parsing the dataset text. Today, only `content` (the assembled dataset file text) is stored there; I would have added GitHub&#039;s raw API response (PR + review + reactions), to keep an exploitable trace independent of the generated text format.\n7. **Exploit the GitHub JSON reactions**: Reactions would actually lend themselves to more than a simple filter: they could weight the search score itself, not just be returned in the `content`. Since version 1.14, Qdrant offers a [*Formula Query*](https://qdrant.tech/documentation/search/hybrid-queries/) that lets you compose a final score from the initial similarity score and payload fields, in a single re-ranking formula. A review with ten `+1` would then rank above an isolated review with zero reactions, at equal vector similarity — a way to surface the opinions the community itself validated. A feature worth exploring.\n\n## Does it work?\n\nThe big question: &quot;are the reviews produced actually better?&quot;.\n\nAnswering Yes would be partially wrong. Indeed, today my evaluation remains essentially based on a general feeling rather than on real, tangible, quantifiable data.\nThe generated answers seem to me far more in the tone of a review that would have been made on Symfony&#039;s GitHub repository, and therefore closer to its conventions, than the answers obtained with a context-free LLM.\nFinally, since an LLM is &quot;probabilistic&quot; by nature, I don&#039;t believe it&#039;s relevant to assert, based on my own observations and personal feeling alone, that the functioning of a tool is established.\n\nHere is a example of a review done on that project to give you an idea :\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## To conclude\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\nTry it yourself — the [project](https://github.com/ktherage/symfony-review-mcp) is designed to be self-contained and independent. The data is publicly accessible, and you can spin up a Qdrant and Ollama instance easily with [Docker](https://www.docker.com/).\n\nTo install it:\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\nThe most surprising thing I learned building this project: PHP is a perfectly viable language for RAG pipelines. Symfony&#039;s HttpClient, Cache, and Console components, combined with the [`symfony/ai-*`](https://github.com/symfony/ai) packages, handle everything from HTTP decoration to vector database operations. \n\nYou don&#039;t need Python to do semantic search.\n\nSometimes the best tool for the job is the one you already master."
      }
    },
    {
      "type": "blog",
      "id": "blog/2026/the-cache-that-silently-wasnt",
      "url": "https://ktherage.github.io/blog/2026/the-cache-that-silently-wasnt/",
      "attributes": {
        "alias": "/blog/the-cache-that-silently-wasnt/",
        "title": "The Cache That Didn't Cache: A Symfony Serialization Story",
        "date": "2026-06-10T00:00:00+00:00",
        "description": "How a Closure inside getInfo(), a silent exception, and an unchecked return value created a perfect false success.",
        "cover": {"image":"img/pexels-black-hole-23522813.jpeg","alt":"Black and white view of a black hole surrounded by swirling stars in a spiral galaxy","caption":"Photo by <a href=\"https://www.pexels.com/@icebergsano-427049742/\">Iceberg San</a> on <a href=\"https://www.pexels.com\">Pexels</a>"},
        "published": true,
        "tags": ["Symfony","PHP","Debug","Cache","Serialization"],
        "excerpt": "The logs said the cache was working. The filesystem could testify to it. Here is how a hidden Closure in getInfo(), silent exception handling, and an ignored return value created a perfect, silent nightmare.",
        "body": "I hit a bug where caching HTTP responses seemed to work perfectly according to the logs, yet no files ever appeared in `var/http_cache/`. No files. No errors. Just pure silence.\n\n## The Context\n\nI am building an MCP server to expose a RAG and avoid redundant API calls during development. The filesystem cache sits between the application and the external API I use to construct my RAG.\n\nThe HTTP client chain follows a classic decorator pattern (from top to bottom in the decoration chain):\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` combines Symfony&#039;s `ScopingHttpClient` (for scoping and authenticating with the API) with a `FilesystemAdapter` to persist HTTP responses in `var/http_cache/`. A `CachedResponse` class implements `ResponseInterface` so that cached responses look identical to fresh ones.\n\n## The Symptom\n\n```\napp.DEBUG: Storing Response to cache with key c3f36f73afae200bb284436334b6647f.\napp.DEBUG: Response stored to cache with key c3f36f73afae200bb284436334b6647f.\n```\n\nThe debug logs confirmed the caching attempts. The reality: `var/http_cache/` remained completely empty.\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## The Analysis\n\n### 1. The `FilesystemAdapter` — The Red Herring That Helped Me Understand\n\nSo the question at that point was: Why? Why isn&#039;t it saving my responses to the cache?\n\n### 1.1. Is there an issue with the filesystem?\n\nI initially thought it was coming from `Symfony\\Component\\Cache\\Adapter\\FilesystemAdapter`, and digging into the `vendor` files revealed the following path:\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()` called `Symfony\\Component\\Cache\\Marshaller\\DefaultMarshaller::marshall()`, which used PHP&#039;s native `serialize()` before handing the return value over to `Symfony\\Component\\Cache\\Traits\\FilesystemCommonTrait::write()`.\n\nLooking closely at the code, I noticed that `FilesystemCommonTrait::write()` runs PHP&#039;s `mkdir()` prefixed with an `@` operator, silencing any directory creation errors. Meaning, if there was an issue with my cache directory, it would be suppressed entirely. I tried running `chmod -R 777 var/http_cache/`, but to no avail.\n\n### 1.2. Is there an issue with serialization?\n\nThe only thing left to check was whether the issue came from serialization itself. I wrote a minimal reproduction script to figure it out:\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# Output:\n# Serialization FAILED: Serialization of &#039;Closure&#039; is not allowed\n```\n\nThe failure happens during `serialize()` — long before any file operations occur. The `CachedResponse` object contained non-serializable data, which in my case turned out to be a `Closure`.\n\n### 2. Locating the Unserializable Element\n\nThe new question now was: Where on earth could a `Closure` be lurking inside my `CachedResponse`?\n\n### 2.1. CachedResponse\n\nThis class is quite straightforward and is built from an instance of `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. Process of Elimination\n\nBy a process of elimination, only `getInfo()` could contain this kind of data, because:\n- `statusCode`: returns an `int` corresponding to the HTTP status code.\n- `headers`: returns HTTP headers, which are fundamentally arrays of `int` or `string`.\n- `content`: returns the response body, which is just a `string`, so no `Closure` there.\n- `toArray`: would have thrown an exception already if the `content` wasn&#039;t valid JSON.\n\nSo, there had to be something unexpected inside `getInfo()`. Inspecting `getInfo()` revealed the culprit via this script:\n\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# Output:\npause_handler =&gt; Closure\n```\n\nSymfony’s HTTP client includes a `pause_handler` key inside `getInfo()`, which contains a `Closure` used internally for retry logic (handling `429 Too Many Requests` with `Retry-After`). PHP, however, cannot serialize a `Closure`.\n\n### 3. Why the Failure Was Silent\n\nThree layers of code completely masked the root cause:\n\n**Layer 1 — Ignored Return Value / My Mistake**\n\nMy mistake was failing to check the return value of the `save()` function. I hadn&#039;t realized at the time that it returns a boolean indicating whether the item was successfully stored.\n\nSo I went from:\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\nTo:\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\nThis gave me more relevant logs, showing a `Cache save: FAILED` message on every single attempt. The `save()` method was failing, meaning my cache had never actually worked.\n\n**Layer 2 — Silent Exception Handling in the Marshaller**\n\n`serialize()` was failing silently because, inside `Symfony\\Component\\Cache\\Marshaller\\DefaultMarshaller::marshall()`, Symfony catches serialization exceptions by default and populates an array with the IDs of failed serializations.\n\nHere is a simplified version of that function:\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\nWith `throwOnSerializationFailure` defaulting to `false`, exceptions are swallowed. The failed cache key goes into `$failed`, but no warning is ever logged.\n\n**Layer 3 — Serializing a Mixed Data Array Fully**\n\nStoring a mixed data array completely in the cache was another mistake of mine — _though only half mine, as I didn&#039;t anticipate the `Closure` inside `getInfo()`_. In hindsight, not everything is worth keeping.\n\n## The Fix\n\n**Validate cache operation results:**\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**Filter out `\\Closure` instances from `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## Prevention\n\nThis bug wasn&#039;t an issue with Symfony&#039;s cache system — it was working exactly as designed. The failure came from three aligned oversights:\n\n1. **Forgetting to check return values**  \n   Methods return values for a reason. Treat methods returning a `bool` like `save()` as contracts.\n\n2. **Neglecting silent exception handling**  \n   Frameworks sometimes prioritize silence over visibility. Know where to flip the switch for verbosity (`throwOnSerializationFailure: true` in dev environments).\n\n3. **Assuming `getInfo()` only contains scalar data**  \n   Internal mechanics like `pause_handler` can leak into metadata. Always validate what you cache."
      }
    },
    {
      "type": "blog",
      "id": "blog/2026/dealing-with-isolated-phpstan-1-and-the-phpunit-13-blindspot",
      "url": "https://ktherage.github.io/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": "Dealing with Isolated PHPStan 1 and the PHPUnit 13 Blindspot",
        "date": "2026-05-29T00:00:00+00:00",
        "description": "How isolating your QA tools can cause phantom 'unknown class TestCase' errors after upgrading to PHPUnit 13, and how upgrading to PHPStan 2.0 solves it.",
        "cover": {"image":"img/pexels-puzzle-missing-piece.jpg","alt":"White jigsaw puzzle with one missing piece revealing a blue background","caption":"Photo by <a href=\"https://www.pexels.com/@karolina-grabowska/\">Karolina Grabowska</a> on <a href=\"https://www.pexels.com\">Pexels</a>"},
        "published": true,
        "tags": ["PHPStan","PHPUnit","QA Tools","Testing"],
        "excerpt": "Shoving PHPStan into a separate subdirectory is great for avoiding dependency hell—until you upgrade to PHPUnit 13 and your static analysis pipeline goes completely blind. Here is how to fix it.",
        "body": "We love isolated dev tools. Shoving PHPStan, Rector, or PHP CS Fixer into separate subdirectories like `.tools/phpstan/` with their own `composer.json` is a great way to avoid dependency hell in your root project.\n\nUntil it completely blinds your analysis pipeline.\n\nIf you recently jumped to **PHPUnit 13** and your static analysis suddenly went off the rails with phantom errors like `unknown class PHPUnit\\Framework\\TestCase`, you&#039;ve hit a classic isolation wall. Let&#039;s look at why it breaks and how to fix it properly.\n\n---\n\n## The Symptom\n\nYour test suite runs inside Docker. It passes flawlessly. Every assertion goes green.\nYet, the moment you run PHPStan, your terminal explodes:\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```\n\nYou look at your `phpstan.neon.dist`. You already bridged the gap by telling PHPStan where to find the project&#039;s autoloader:\n\n```yaml\nparameters:\n    level: max\n    paths:\n        - src/\n        - tests/\n    bootstrapFiles:\n        - vendor/autoload.php\n\n```\n\nYou even double-check the autoloader manually 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\nThe console returns `🟢 OUI`. The class is right there. So why is PHPStan blind?\n\n---\n\n## Why Did This Work Fine on PHPUnit 9?\n\nIf you have this exact same layout running on an older project with PHPUnit 9.5, it works without a hitch. What changed?\n\n### 1. The Architectural Shift in PHPUnit 10+\n\nIn PHPUnit 9, `TestCase` was a pretty monolithic class. PHPStan&#039;s static reflection engine (`BetterReflection`) had no trouble mapping it from an external directory.\n\nWith PHPUnit 10 (and up through v13), the framework was completely refactored. `TestCase` now relies on a deep web of internal interfaces and traits. When PHPStan tries to inspect it remotely across directory boundaries via a bootstrapped autoloader, the reflection engine gets lost in the inheritance tree and safely assumes the class doesn&#039;t exist.\n\n### 2. The Legacy Extension Trap\n\nIf you look at your isolated `.tools/phpstan/composer.json`, you probably pulled a legacy constraint from an older project boilerplate:\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```\n\nThat `^1.1` constraint locks the PHPUnit extension to its **1.x branch**, which was historically built for PHPUnit 9. Because the extension is locked to v1.x, Composer silently pins core `phpstan/phpstan` to a legacy version too (like `1.12.x`), completely ignoring your `*` wildcard. You are effectively analyzing a modern PHPUnit 13 codebase with an outdated engine.\n\n---\n\n## The Clean Fix: Drop the Legacy Constraints\n\nInstead of fighting paths with `scanDirectories` or stuffing a dummy copy of PHPUnit into your tools directory, just upgrade your toolchain. **PHPStan 2.0** and its **phpstan-phpunit 2.0** extension handle the complex architecture of modern PHPUnit natively.\n\n### 1. Bump to v2\n\nOpen `.tools/phpstan/composer.json` and force the upgrade:\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\n### 2. Refresh the Environment\n\nRun an update inside your tools directory to rebuild the lock file:\n\n```bash\ncd .tools/phpstan &amp;&amp; composer update\n\n```\n\n### 3. Clear Cache &amp; Analyze\n\nMake sure your `phpstan.neon.dist` uses the absolute path variable to target your root vendor directory securely:\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```\n\nNuke the old analysis cache so you don&#039;t run into stale results:\n\n```bash\n.tools/phpstan/phpstan clear-result-cache\n\n```\n\nRun your analyzer again. The phantom errors will disappear, and you&#039;ll get your clean green light back without degrading your isolated architecture."
      }
    },
    {
      "type": "blog",
      "id": "blog/2026/ubuntu-25-10-docker-java-cgroupv2-crash",
      "url": "https://ktherage.github.io/blog/2026/ubuntu-25-10-docker-java-cgroupv2-crash/",
      "attributes": {
        "alias": "/blog/ubuntu-25-10-docker-java-cgroupv2-crash/",
        "title": "Ubuntu 25.10: The Update That Bricked My Java Docker Containers",
        "date": "2026-04-28T00:00:00+00:00",
        "description": "How a simple Ubuntu update crashed my Selenium containers, and why the cgroupv2 NullPointerException is a symptom of a compatibility issue between the Linux kernel and legacy Java versions.",
        "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/](https://www.pexels.com/@thorl5/)\">thorl5</a> on <a href=\"[https://www.pexels.com](https://www.pexels.com)\">Pexels</a>"},
        "published": true,
        "updated": "2026-06-18T00:00:00+00:00",
        "tags": ["Ubuntu","Docker","cgroupv2","Debug"],
        "excerpt": "After updating my operating system from Ubuntu 24.04 to 25.10, my selenium/standalone-chrome Docker container started crashing with a cryptic NullPointerException. Here is my story.",
        "body": "## The Quiet Update That Turned Into a Nightmare\n\nIt was a Friday night, just like any other. I finally decided to do what every good developer avoids: **updating my OS**. Ubuntu 24.04 LTS → 25.10, just a routine little update. *&quot;It can only get better,&quot;* I told myself with that specific brand of pessimism reserved for those who have seen too many updates go south (Ubuntu 24.10, I’m looking at you!).\n\nI launched the update with confidence. Everything went well. Reboot. Everything works. Perfect.\n\nExcept that the following Monday, my E2E tests were failing. The `selenium/standalone-chrome:4.5.3` Docker container, which worked perfectly the day before, now refused to start. It was crashing in a loop with this magnificent error message:\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)\n...\n```\n\n**My first thought:**\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; I&#039;m a PHP developer, not a Java one.\n\n**My reflex:**\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; Let&#039;s ask someone smarter than me. Gemini cricket (🤖🦗) 🥲.\n\n## The Investigation\n\nMy cricket friend pointed out **cgroupv2** on line `CgroupV2Subsystem.java:81` and linked it to my system update. But what exactly is **cgroupv2**?\n\n🤖🦗:\n&gt; Long story short, it&#039;s what allows Docker 🐋 to limit a container&#039;s CPU or RAM.\n\n\n\nIn concrete terms:\n* Docker tells the JVM: *&quot;You are allowed 2GB of RAM.&quot;*\n* Java reads this info from **cgroups** (resource management files).\n* Java adjusts its behavior (Heap memory, etc.) accordingly.\n\n**This is supposed to be a good thing.** It prevents Java from being slaughtered by the host system&#039;s *OOM Killer*. However, Java must parse these files, and that’s where things fall apart.\n\n&gt; **Why a crash instead of just an error?**\n&gt; In the source code of older JVMs, if the path returned by the system interface isn&#039;t exactly what&#039;s expected, the `mountPoint` variable remains `null`. The JVM then attempts to call a method on this non-existent object. It&#039;s a classic backfire: the function meant to protect your application becomes the very cause of its summary execution.\n\n## The Plot Twist: The Subtle Difference Between Ubuntu 24.04 and 25.10\n\nThis is where it gets fascinating.\n\nThe real culprit is the evolution of **systemd** (now at version 258 in Ubuntu 25.10). Since v256, systemd has enforced a &quot;hardening&quot; of the cgroup v2 hierarchy. It no longer just exposes controllers; it organizes them in a much more granular way to isolate services. Legacy Java versions, designed back when the hierarchy was more predictable and less protected, find themselves literally &quot;blind&quot; to this new structure.\n\n**And guess what?** The old Java initialization logic (pre-Java 17) is far too rigid to understand this new format.\n\nAt startup, the Java inside our container scouts the system, fails to find the &quot;memory&quot; controller exactly where it expected, and silently assigns `null` to its internal variable. On the very next line, the code tries to call the `.getMountPoint()` method on this empty object.\n\n**BOOM**. NullPointerException. Instant process death.\n\nThe culprit wasn&#039;t our code or our Docker config, but an old JVM incapable of adapting to a modern Linux kernel&#039;s new hierarchy. *Fair enough, you might say.*\n\n## THE Solution\n\nThe clean solution, the one you should always use in production:\n\n```bash\n# Update to a recent version of the image\ndocker pull selenium/standalone-chrome:4.20.0\n\n# OR use an image with Java 17+\ndocker pull selenium/standalone-chrome:latest\n```\n\n## The Survival Hack — Disabling UseContainerSupport\n\nIf you cannot update the image (legacy constraints, QA validation, etc.), you can disable container detection:\n\n```bash\n# Option 1: Via Docker environment variable\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**Beware:** if you have existing options defined, mind to separate them with a space. Exemple : `JAVA_OPTS=&quot;SOME_EXISTING_OPTIONS -XX:-UseContainerSupport`\n:::\n\n:::caution\n**⚠️ Important Warning:**\n\n- **DO NOT DO THIS IN PRODUCTION.** In my case, this is a **local development** container.\n- Without `UseContainerSupport`, Java is unaware of its limits and can be terminated by the host system&#039;s OOM Killer.\n- This solution is a **temporary band-aid** while waiting for an update.\n:::\n\n## The Morale of the Story\n\nOur software ecosystems are **fragile**. A simple Linux kernel update—via an Ubuntu update in my case—can break containers that worked perfectly from one version to another.\n\n**Philosophical advice:**\n\n&gt; *Remembering to &quot;clean your room&quot; regularly (I&#039;m sure you get the metaphor) can save a lot of time.*\n\n&gt; *Never perform an OS update on a Friday.*\n\n&gt; *Always make sure to test your Docker containers in a staging environment after a system update.*\n\n&gt; *&quot;Works on my machine&quot; — until the kernel updates.*\n\n## Sources and References\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": "blog/2026/my-eventsubscriber-silenced-errors",
      "url": "https://ktherage.github.io/blog/2026/my-eventsubscriber-silenced-errors/",
      "attributes": {
        "alias": "/blog/my-eventsubscriber-silenced-errors/",
        "title": "My EventSubscriber silenced errors, here's why",
        "date": "2026-04-13T00:00:00+00:00",
        "description": "How my whitelist route EventSubscriber was hiding real errors and how I fixed it.",
        "cover": {"image":"img/terminal-code.jpg","alt":"Computer program language text","caption":"Photo by <a href=\"https://www.pexels.com/@nathan-dumlao/\">Nathan Dumlao</a> on <a href=\"https://www.pexels.com\">Pexels</a>"},
        "published": true,
        "tags": ["Symfony","Debug","Security"],
        "excerpt": "My whitelist route EventSubscriber was throwing AccessDenied errors in logs with no apparent reason. Here's how I discovered it was actually hiding the real error behind the scenes.",
        "body": "A Jira ticket came out with : *&quot;There&#039;s a strange bug disallowing users to access a page at that time that day.&quot;*\nLogs said multiple times : *&quot;[that day T that time] request.ERROR: Uncaught PHP Exception Symfony\\Component\\HttpKernel\\Exception\\AccessDeniedHttpException: &quot;Access denied to that resource.&quot; at WhitelistSubscriber.php line 99&quot;*\n\nI had no idea at first... 😅 Here&#039;s how I figured it out.\n\n---\n\n## The Setup\n\nI had an EventSubscriber checking page access based on a whitelist of routes. This was legacy code — refactoring it wasn&#039;t on the table at the time.\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\nGoal: Block all routes except the whitelist. Simple, right?\n\n---\n\n## The Problem\n\nLogs were showing `AccessDeniedHttpException` on routes I knew were whitelisted. Classic first move: throw a `dump()` inside the subscriber to see what was coming in.\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); // 🔍 Let&#039;s see what&#039;s happening\n    // ...\n}\n```\n\nFirst surprising finding: **the subscriber was being called twice** for a single request. The first call had the expected route, the second had `$route = null`.\n\nObvious question: *why is `_route` null?*\n\nI dug further with `dump($request-&gt;getPathInfo())` to see what URL was being processed on the second call:\n\n```\n// 1st call\ndump($request-&gt;getPathInfo()); // &quot;/foo&quot;\n\n// 2nd call\ndump($request-&gt;getPathInfo()); // &quot;/foo&quot; ← same. Wait, what?\n```\n\nSame URL, called twice. That made no sense — if it was the same request, why was `_route` null the second time? I was going in circles.\n\nSo I dumped the full `$event` object to get more context, and narrowed it down to `_controller` in the request attributes:\n\n```php\ndump($request-&gt;attributes-&gt;get(&#039;_controller&#039;));\n// &quot;Symfony\\Component\\HttpKernel\\Controller\\ErrorController&quot;\n```\n\nThere it was. `_controller` wasn&#039;t pointing to my code at all. Symfony had forged a brand new request to its own `ErrorController`, reusing the original URL — which is why `getPathInfo()` was so misleading — but bypassing the router entirely. That&#039;s why `_route` was null.\n\n---\n\n## Root Cause\n\nThe actual flow was:\n\n```\nRequest → /foo\n  └── WhitelistSubscriber (1st call) → _route = &#039;app_foo&#039; ✅ Access granted\n      └── Controller → throws RealException 💥\n          └── Symfony catches it\n              └── Sub-request → ErrorController (bypasses router, no _route)\n                  └── WhitelistSubscriber (2nd call) → _route = null ❌ AccessDenied thrown\n                      └── RealException is now silenced 🔇\n```\n\nThe trap: **the subscriber&#039;s `AccessDeniedHttpException` was completely masking the original exception** — the one that actually contained the useful debug information.\n\nWhen an exception is thrown, Symfony&#039;s `HttpKernel` dispatches a `KernelEvents::EXCEPTION` event, then delegates the error rendering to `ErrorController` via an internal sub-request. That sub-request reuses the original URL — which is why `getPathInfo()` was misleading — but it completely bypasses the routing layer, leaving `_route` as `null`.\n\n---\n\n## The Solution\n\nCheck if the request is the main request (not a sub-request):\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()` returns `false` for any internal sub-request — error handling, ESI fragments, `hinclude` — so your logic only runs on real, router-dispatched requests.\n\n&gt; **Note:** `isMainRequest()` replaced the deprecated `isMasterRequest()` in Symfony 5.3. If you&#039;re on an older version, use `isMasterRequest()` instead.\n\n---\n\n## Takeaway\n\nAnytime your subscriber does something destructive — throw, redirect, set a response — ask yourself: *what happens when Symfony calls this on a sub-request?*\n\nSub-requests are everywhere in Symfony: error handling, ESI, fragments. They don&#039;t carry the same context as a main request, and your subscriber doesn&#039;t know the difference unless you tell it to.\n\n`isMainRequest()` is that check. Make it a reflex. 🎉"
      }
    },
    {
      "type": "blog",
      "id": "blog/2026/gitignore-blacklisting-whitelisting",
      "url": "https://ktherage.github.io/blog/2026/gitignore-blacklisting-whitelisting/",
      "attributes": {
        "alias": "/blog/gitignore-blacklisting-whitelisting/",
        "title": "Debugging Git's .gitignore: Why Whitelisting Files in Subdirectories Fails",
        "date": "2026-03-25T00:00:00+00:00",
        "description": "A deep dive into Git's .gitignore directory traversal rules and how to avoid common pitfalls when whitelisting files in subdirectories.",
        "cover": {"image":"img/pexels-photo-577585.jpeg","alt":"Image from www.pexels.com - Credits Kevin Ku","caption":"Image from <a href=\"https://www.pexels.com\">www.pexels.com</a> - Credits <a href=\"https://www.pexels.com/@kevin-ku-92347/\">Kevin Ku</a>"},
        "published": true,
        "tags": ["Git"],
        "excerpt": "When using .gitignore to keep your project clean, it's easy to accidentally hide important files in subdirectories. Here's how to debug and fix this common issue.",
        "body": "## Introduction\n\nWhen working with Git, it&#039;s common to use `.gitignore` to exclude files and directories. But sometimes, even well-intentioned rules can lead to unexpected behavior—especially when dealing with nested directories. In this post, I&#039;ll walk through a real-world example of how a `.gitignore` rule intended to keep a project clean ended up hiding important files, and how we fixed it.\n\n---\n\n## The Setup\n\nI wanted to keep the `.tools/` directory clean, tracking only `composer.json`, `composer.lock`, and the `.gitignore` file itself. My initial `.tools/.gitignore` looked like this:\n\n```gitignore\n*\n!.gitignore\n!composer.json\n!composer.lock\n```\n\nGoal: Track only composer.json, composer.lock, and .gitignore in .tools/ and its subdirectories, ignoring everything else.\n\n---\n\n## The Problem\n\nAfter pushing this change, a colleague reported that their composer.lock file in `.tools/rector/` was being ignored. We used the following command to debug:\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## Root Cause\n\nGit&#039;s rule: *&quot;It is not possible to re-include a file if a parent directory of that file is excluded.&quot;*\n\nThe `*` pattern ignores both files and directories, which means Git never even looks inside `.tools/rector/`—so the whitelist rules for `composer.json` and `composer.lock` never apply.\n\n---\n\n## The Solution\n\nAfter debugging, we updated the `.gitignore` to explicitly allow directory traversal and re-include the necessary files:\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## Key Takeaways\n\n| Directory/File | Rule Applied | Result |\n|----------------|--------------|--------|\n| .tools/ | `*` | Ignored |\n| .tools/rector/ | `!*/` | Inspected |\n| .tools/rector/vendor | `vendor` | Ignored |\n| .tools/rector/composer.json | `!*/composer.json` | Tracked |\n\n- **Git&#039;s Directory Traversal:** When you use `*` to ignore everything, Git won&#039;t look inside directories unless you explicitly allow it with `!*/`.\n- **Testing Your Rules:** Always test your `.gitignore` with `git check-ignore -v &lt;file&gt;` and `git status` to ensure the expected files are tracked.\n- **Order Matters:** Place general exclusions first, then re-include specific files or directories.\n- **Common Pitfalls:** Remember to re-exclude directories like `vendor` after whitelisting, or they&#039;ll be included in your repository.\n\n---\n\n## Conclusion\n\nDebugging `.gitignore` issues can be tricky, but understanding how Git evaluates directory traversal and pattern matching makes it much easier. Always test your rules with nested directories before committing, and don&#039;t hesitate to use `git check-ignore` to verify your setup."
      }
    }
  ]
}