Introduction
Symfony Filesystem Cache Bridge
A Symfony Cache adapter backed by Flow PHP's native Filesystem library. Cache items are stored as files on top of any filesystem the library can mount — local disk, in-memory, AWS S3, Azure Blob — without depending on Symfony's FilesystemAdapter and the local-only assumptions baked into it.
Installation
composer require flow-php/symfony-filesystem-cache-bridge:~0.36.0
For Symfony framework integration (config-driven pool registration) use this bridge through the Symfony Filesystem Bundle. The page below covers standalone usage.
How It Works
FlowFilesystemCacheAdapter extends Symfony's AbstractAdapter and implements PruneableInterface. Items are stored one-per-file under a base directory you provide:
<base>/
ab/
qZ5K-… ← single cache item
c4/
Mn7p-…
- The 2-character shard directory is
substr(str_replace('/', '-', base64_encode(hash('xxh128', $id, true))), 0, 2)— the same recipe as Symfony's built-inFilesystemAdapter, so the on-disk layout is familiar. - Each file body has three newline-separated parts: a 10-digit zero-padded expiry timestamp (
0000000000for "no expiry"), the cache id, and the marshalled value. - Saves write to a randomised temp path (
Path::randomize()), thenmv()to the final path. Ifmv()returnsfalsethe temp file is removed and the failure is reported to the configured logger as aFilesystemCacheException. - Reads stream the file once, split on
"\n", drop the row when the expiry has passed and calldoDelete()for the expired ids in the same call. prune()walks the base directory recursively, reads only the first line of every file, and deletes any file whose expiry is in the past.- Marshalling uses Symfony's
DefaultMarshallerby default; pass a customMarshallerInterfaceto the constructor to override.
Filesystem Ownership
The adapter does NOT create or own a Filesystem. You hand it any Flow\Filesystem\Filesystem instance and a Path for the base directory:
- Local disk — pass
new NativeLocalFilesystem()and aPath::from('/var/cache/app'). - Remote object store — pass an S3 or Azure-backed filesystem; the adapter writes through it transparently.
- Same instance, multiple pools — give two adapters the same
Filesystembut differentPathdirectories (or differentnamespacestrings) to keep their files isolated.
The Path value is the only state the adapter holds about location — every getItem/save/clear call composes the per-item path from it, so changing the directory means re-instantiating the adapter.
Usage
use Flow\Bridge\Symfony\FilesystemCache\FlowFilesystemCacheAdapter;
use Flow\Filesystem\Local\NativeLocalFilesystem;
use Flow\Filesystem\Path;
$cache = new FlowFilesystemCacheAdapter(
filesystem: new NativeLocalFilesystem(),
directory: Path::from('/var/cache/app'),
namespace: 'app',
defaultLifetime: 3600,
);
$item = $cache->getItem('greeting');
if (!$item->isHit()) {
$item->set('hello world');
$item->expiresAfter(600);
$cache->save($item);
}
echo $cache->getItem('greeting')->get();
The same code works against flow-php/filesystem-async-aws-bridge or flow-php/filesystem-azure-bridge — swap the Filesystem argument and the cache lives in S3 or Azure Blob.
Constructor
new FlowFilesystemCacheAdapter(
Flow\Filesystem\Filesystem $filesystem,
Flow\Filesystem\Path $directory,
string $namespace = '',
int $defaultLifetime = 0,
?Symfony\Component\Cache\Marshaller\MarshallerInterface $marshaller = null,
);
$namespace is validated against [-+.A-Za-z0-9] — same rule Symfony enforces. $directory must be a non-pattern path; the adapter creates the shard directories on demand via the underlying filesystem's writeTo().
File Layout
| Section | Format | Notes |
|---|---|---|
| Line 1 | printf('%010d', $expiry) |
Zero-padded UNIX timestamp; 0000000000 means "never expires" |
| Line 2 | raw cache id | Used by clear($prefix) to match items by namespace prefix |
| Line 3+ | marshaller output | Whatever MarshallerInterface::marshall() produced (binary safe) |
The shard prefix keeps any single directory bounded — even with millions of keys, no shard dir holds more than ~items / 256 / 256 entries on average, and the tree never grows wider than 256 sub-directories.
Pruning
The filesystem has no built-in TTL, so expired files are removed by either of two paths:
- Lazy — a read of the same key triggers a delete of that one file.
- Explicit — calling
$cache->prune()(orbin/console cache:pool:prunewhen the adapter is wired into a Symfony app) walks the base directory and deletes everything past its expiry.
Without periodic pruning, dead files accumulate even though they are invisible to readers.