Cover image for The Cache That Didn't Cache: A Symfony Serialization Story

Photo by Iceberg San on Pexels

The Cache That Didn't Cache: A Symfony Serialization Story

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.

The Context

I am building an MCP server to expose a RAG and avoid redundant API calls during development. The filesystem cache sits between the application and the external API I use to construct my RAG.

The HTTP client chain follows a classic decorator pattern (from top to bottom in the decoration chain):

flowchart TD
    A[Symfony\Component\HttpClient\HttpClient\ScopingHttpClient] --> B
    B["CachedHttpClient (stratégie de cache perso)"] --> C
    C["LoggedHttpClient (stratégie de logging perso)"] --> D
    D["Symfony\Component\HttpClient\HttpClient::create()"]

CachedHttpClient combines Symfony'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.

The Symptom

app.DEBUG: Storing Response to cache with key c3f36f73afae200bb284436334b6647f.
app.DEBUG: Response stored to cache with key c3f36f73afae200bb284436334b6647f.

The debug logs confirmed the caching attempts. The reality: var/http_cache/ remained completely empty.

The Analysis

1. The FilesystemAdapter — The Red Herring That Helped Me Understand

So the question at that point was: Why? Why isn't it saving my responses to the cache?

1.1. Is there an issue with the filesystem?

I initially thought it was coming from Symfony\Component\Cache\Adapter\FilesystemAdapter, and digging into the vendor files revealed the following path:

flowchart TD
    A["Symfony\Component\Cache\Adapter\FilesystemAdapter::save()"] --> B
    B["Symfony\Component\Cache\Traits\AbstractAdapterTrait::save()"] --> C
    C["Symfony\Component\Cache\Adapter\AbstractAdapter::commit()"] --> D
    D["Symfony\Component\Cache\Traits\FilesystemTrait::doSave()"] --> E
    E["Symfony\Component\Cache\Marshaller\DefaultMarshaller::marshall()"] --> F{"calls serialize()"}
    F["Symfony\Component\Cache\Traits\FilesystemCommonTrait::write()"]

Symfony\Component\Cache\Traits\FilesystemTrait::doSave() called Symfony\Component\Cache\Marshaller\DefaultMarshaller::marshall(), which used PHP's native serialize() before handing the return value over to Symfony\Component\Cache\Traits\FilesystemCommonTrait::write().

Looking closely at the code, I noticed that FilesystemCommonTrait::write() runs PHP'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.

1.2. Is there an issue with serialization?

The only thing left to check was whether the issue came from serialization itself. I wrote a minimal reproduction script to figure it out:

docker compose exec cli sh -c "php -r '
require \"/srv/vendor/autoload.php\";

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

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

\$cached = new CachedResponse(\$response);
try {
    \$serialized = serialize(\$cached);
    echo \"Serialization OK\\n\";
} catch (\Exception \$e) {
    echo \"Serialization FAILED: \" . \$e->getMessage() . \"\\n\";
}
' 2>&1
# Output:
# Serialization FAILED: Serialization of 'Closure' is not allowed

The 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.

2. Locating the Unserializable Element

The new question now was: Where on earth could a Closure be lurking inside my CachedResponse?

2.1. CachedResponse

This class is quite straightforward and is built from an instance of Symfony\Contracts\HttpClient\ResponseInterface.

<?php

declare(strict_types=1);

namespace App\HTTP;

use Symfony\Contracts\HttpClient\ResponseInterface;

final class CachedResponse implements ResponseInterface
{
    private int $statusCode;
    private array $headers;
    private string $content;
    private array $toArray;
    private array $info;

    public function __construct(ResponseInterface $response)
    {
        $this->statusCode = $response->getStatusCode();
        $this->headers = $response->getHeaders();
        $this->content = $response->getContent();
        $this->toArray = $response->toArray();
        $this->info = $response->getInfo();
    }

    // ...
}

2.2. Process of Elimination

By a process of elimination, only getInfo() could contain this kind of data, because:

  • statusCode: returns an int corresponding to the HTTP status code.
  • headers: returns HTTP headers, which are fundamentally arrays of int or string.
  • content: returns the response body, which is just a string, so no Closure there.
  • toArray: would have thrown an exception already if the content wasn't valid JSON.

So, there had to be something unexpected inside getInfo(). Inspecting getInfo() revealed the culprit via this script:

docker compose exec cli php -r '
require "/srv/vendor/autoload.php";
use Symfony\Component\HttpClient\HttpClient;
\$client = HttpClient::create();
\$response = \$client->request("GET", "https://api.github.com/repos/symfony/symfony/pulls/64552", [
    "headers" => ["Accept" => "application/vnd.github+json", "User-Agent" => "Test"],
]);
foreach (\$response->getInfo() as \$k => \$v) {
    if (\$v instanceof \\Closure) echo "\$k => Closure\\n";
}
'
# Output:
pause_handler => Closure

Symfony’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.

3. Why the Failure Was Silent

Three layers of code completely masked the root cause:

Layer 1 — Ignored Return Value / My Mistake

My mistake was failing to check the return value of the save() function. I hadn't realized at the time that it returns a boolean indicating whether the item was successfully stored.

So I went from:

$this->cache->save($cacheItem); // returns false, ignored
$this->logger->debug("Response stored to cache with key {$key}.");

To:

$saved = $this->cache->save($cacheItem);
$this->logger->debug("Cache save: {result}", ['result' => $saved ? 'success' : 'FAILED']);

This 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.

Layer 2 — Silent Exception Handling in the Marshaller

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.

Here is a simplified version of that function:

public function marshall(array $values, ?array &$failed): array
{
    $serialized = $failed = [];

    foreach ($values as $id => $value) {
        try {
            $serialized[$id] = serialize($value);
        } catch (\Exception $e) {
            if ($this->throwOnSerializationFailure) {
                throw new \ValueError($e->getMessage(), 0, $e);
            }
            $failed[] = $id;
        }
    }

    return $serialized;
}

With throwOnSerializationFailure defaulting to false, exceptions are swallowed. The failed cache key goes into $failed, but no warning is ever logged.

Layer 3 — Serializing a Mixed Data Array Fully

Storing a mixed data array completely in the cache was another mistake of mine — though only half mine, as I didn't anticipate the Closure inside getInfo(). In hindsight, not everything is worth keeping.

The Fix

Validate cache operation results:

if (!\$this->cache->save(\$cacheItem)) {
    \$this->logger->warning('Failed to save response to cache', ['key' => \$key]);
    return;
}
\$this->logger->debug("Response stored to cache with key {\$key}.");

Filter out \Closure instances from getInfo():

\$this->info = array_filter(
    \$response->getInfo(),
    static fn (\$v) => !\$v instanceof \Closure
);

Prevention

This bug wasn't an issue with Symfony's cache system — it was working exactly as designed. The failure came from three aligned oversights:

  1. Forgetting to check return values
    Methods return values for a reason. Treat methods returning a bool like save() as contracts.

  2. Neglecting silent exception handling
    Frameworks sometimes prioritize silence over visibility. Know where to flip the switch for verbosity (throwOnSerializationFailure: true in dev environments).

  3. Assuming getInfo() only contains scalar data
    Internal mechanics like pause_handler can leak into metadata. Always validate what you cache.


Comments