cache/filesystem-adapter
PSR-6 and PSR-16 cache pools backed by Flysystem 2.x and 3.x. FilesystemCachePool stores SHA-256 hashed keys in 256 shard directories for portable filenames and smaller directories, with configurable cache folders, filesystem accessors, and custom layouts via getFilePath().
Decide first whether you need this at all. Laravel already ships a file store with Cache::store('file'), tag support, remember(), atomic writes, and php artisan cache:clear wiring. cache/filesystem-adapter earns its place in a Laravel codebase in exactly three situations:
Psr\Cache\CacheItemPoolInterface and you refuse to add Redis for it.file driver cannot do this.league/flysystem-memory) instead of array driver fakery.If none of those apply, stop here and use the native driver. The rest of this assessment assumes one of them does.
composer require cache/filesystem-adapter
composer show league/flysystem cache/filesystem-adapter
This is the single most important command in the whole setup. The package's README is written against Flysystem 1.x (League\Flysystem\Adapter\Local), while Laravel 9+ ships Flysystem 3.x (League\Flysystem\Local\LocalFilesystemAdapter). Check which adapter branch Composer actually resolved before writing any code — the constructor API and class namespaces differ completely, and a mismatch shows up as a Class not found rather than a helpful conflict message.
Flysystem 3 flavour (what a modern Laravel app will resolve to):
use Cache\Adapter\Filesystem\FilesystemCachePool;
use League\Flysystem\Filesystem;
use League\Flysystem\Local\LocalFilesystemAdapter;
$filesystem = new Filesystem(new LocalFilesystemAdapter(storage_path('framework/cache/flysystem')));
$pool = new FilesystemCachePool($filesystem);
Flysystem 1 flavour (legacy apps, matching the README verbatim):
use League\Flysystem\Adapter\Local;
use League\Flysystem\Filesystem;
$filesystem = new Filesystem(new Local(storage_path('framework/cache/flysystem')));
$pool = new FilesystemCachePool($filesystem);
$pool->setFolder('app'); // subfolder *inside* the adapter root — relative, not absolute
Note the subtlety: with Flysystem 1 the Local root is the filesystem root and setFolder() is relative to it; with Flysystem 3 the root moves into the adapter constructor. Getting this wrong writes cache files into your public directory.
FilesystemCachePool itself — it is a thin class over Cache\Adapter\Common\AbstractCachePool. Ten minutes reading it tells you more than any docs page.AbstractCachePool in the cache/adapter-common dependency — that is where getItem/save/hasItem/tag behaviour and hit/miss semantics actually live.Changelog.md — the release notes on Packagist are empty stubs ("See change log"), so the changelog is the only place breaking changes are recorded.Bind a singleton in a service provider and inject it into the library that needs it:
// app/Providers/CacheServiceProvider.php
public function register(): void
{
$this->app->singleton(CacheItemPoolInterface::class, function ($app) {
$root = $app['config']->get('cache.stores.flysystem.root');
$filesystem = new Filesystem(new LocalFilesystemAdapter($root));
return new FilesystemCachePool($filesystem);
});
}
Add a dedicated config block in config/cache.php so the path and TTL are env-driven like every other store, even though Laravel won't consume it as a driver:
'flysystem' => [
'driver' => 'flysystem',
'root' => storage_path('framework/cache/flysystem'),
'ttl' => env('FLYSYSTEM_CACHE_TTL', 600),
],
PSR-6 has no remember(), so every call site needs the same four lines. Wrap it once:
final class PoolCache
{
public function __construct(private CacheItemPoolInterface $pool) {}
public function remember(string $key, int $ttl, callable $callback): mixed
{
$item = $this->pool->getItem($this->normalise($key));
if ($item->isHit()) {
return $item->get();
}
$value = $callback();
$this->pool->save($item->set($value)->expiresAfter($ttl));
return $value;
}
private function normalise(string $key): string
{
return 'app.'.md5($key); // PSR-6 reserves {}()/\@: and caps practical length
}
}
Cache arrays and scalars, never Eloquent models — the pool serialize()s into the file, and a model hydration round-trip costs more than the query it saved. Same discipline as Laravel's own cache, but the penalty here is disk I/O on top.
If you genuinely want Cache::store('flysystem')->remember(...), you need a Illuminate\Contracts\Cache\Store implementation delegating to the pool, registered via Cache::extend:
// app/Cache/FlysystemStore.php
class FilesystemStore implements \Illuminate\Contracts\Cache\Store
{
public function __construct(private CacheItemPoolInterface $pool, private string $prefix = '') {}
public function get($key) {
$item = $this->pool->getItem($this->prefix.$key);
return $item->isHit() ? $item->get() : null;
}
public function put($key, $value, $seconds) {
return $this->pool->save(
$this->pool->getItem($this->prefix.$key)->set($value)->expiresAfter($seconds)
);
}
public function increment($key, $value = 1) { /* see Gotchas — no atomic primitive */ }
public function flush() { return $this->pool->clear(); }
// many(), putMany(), decrement(), forever(), forget(), getPrefix() ...
}
// AppServiceProvider::boot()
Cache::extend('flysystem', fn ($app) => Cache::repository(
new FilesystemStore($app->make(CacheItemPoolInterface::class))
));
Budget two hours, not twenty minutes, and treat increment/decrement as unsupported (return false, or guard them behind a lock). Do this only when a feature flag needs to swap backends without touching call sites — which is exactly the "MVP on files, migrate to Redis later" story PSR-6 sells.
This is the capability Laravel's file driver cannot match: point the pool at S3 or SFTP and share cache between app servers without adding Redis.
$filesystem = new Filesystem(new AwsS3V3Adapter($client, $bucket, 'cache/'.$env));
$pool = new FilesystemCachePool($filesystem);
Be honest about the economics: every getItem() is a network round trip (~20–80 ms to S3), every save() a write, and clear() a list-plus-batch-delete that can take minutes and cost real money on a large prefix. Use it for cold, large, tolerant data — rendered PDFs, third-party API snapshots, sitemap fragments — never for per-request lookups. Isolate it under a dedicated bucket prefix so clear() can't touch anything else.
// tests/TestCase.php
$this->app->instance(CacheItemPoolInterface::class, new FilesystemCachePool(
new Filesystem(new InMemoryFilesystemAdapter())
));
Faster and more predictable than real disk, and it exercises the exact code path production uses. Prefer this over Cache::fake() when the code under test consumes the PSR-6 interface rather than the Laravel facade.
The AbstractCachePool base implements php-cache's taggable interface, so save($item->setTags(['user:42'])) works — but each tag costs an extra write plus a read at fetch time. Reserve tagging for admin-panel and multi-entity invalidation where a coarse clear() would be unacceptable; for per-key expiry, plain TTLs are cheaper on a filesystem.
If dev, staging, and prod share a mounted volume, namespace by environment or you will silently serve staging data in production:
$pool->setFolder('cache-'.app()->environment());
Use one pool per concern (sessions-adjacent data, API responses, exports) rather than one giant folder — clear() is scoped to a folder, so separate folders give you a meaningful "flush just this" button.
Expired items are detected on read, not by the filesystem, so they accumulate. Wire a scheduled command that lists and deletes stale files, or accept periodic clear():
$schedule->call(fn () => Cache::pool('flysystem')->clear())->weekly()->nights();
Flysystem major-version mismatch is the #1 blocker. The README's League\Flysystem\Adapter\Local no longer exists in Flysystem 3, which Laravel 9+ requires. Resolve this before planning any work; if only a 1.x-compatible release exists for your dependency set, the package is effectively unusable in a current Laravel app.
Storage::disk() is not a Flysystem Filesystem. Laravel returns its own Illuminate\Filesystem\FilesystemAdapter wrapper, and getDriver() (the escape hatch) is deprecated/removed in recent versions. You cannot hand Storage::disk('s3') to this pool — construct a parallel League\Flysystem\Filesystem over the same root, and keep the two configurations in sync manually.
Nothing in the Laravel tooling knows about this pool. php artisan cache:clear, cache:forget, Cache::tags(), Telescope's cache panel, and Horizon metrics all ignore it. Every operational action needs a bespoke command. Budget for that, and document it in the runbook.
No atomic writes. Flysystem's local write is essentially file_put_contents — a concurrent reader can see a truncated file and get an unserialize warning or false. Always tolerate a miss:
try {
$item = $pool->getItem($key);
return $item->isHit() ? $item->get() : $fallback();
} catch (\Throwable) {
return $fallback();
}
No atomic increment. PSR-6 has no counter primitive; any increment() you build is read-modify-write and loses updates under concurrency. Laravel's RateLimiter, Cache::lock(), and increment-based counters must not be pointed at this pool — keep the native file/Redis store for anything with a counter.
clear() is a recursive delete inside the folder. Point the adapter root or setFolder() at storage/app, a public disk, or an S3 bucket root and one flush deletes real user data. Always a dedicated, disposable subdirectory; add a boot-time assertion on the configured path.
Permissions bites in the classic way. Files written by www-data aren't readable by the deploy user running php artisan queue:work, producing silent misses that look like cache never warming. Standardise on one group-writable cache directory in the deploy script, as you would for storage/framework/cache.
Expiry lives in the payload, not the mtime. Don't reason about TTLs by inspecting filemtime on cache files — check isHit() in code. Expired items stay on disk until touched, which is why the pruning job above matters.
Size discipline. A 1 MB serialized array is ~1 MB of disk write plus read per request — an order of magnitude slower than a Redis round trip. Keep individual items small (tens of KB); if you need to cache something big, cache a pointer and store the blob as a real file on the same Flysystem disk.
Treat the package as vendored. It's a [READ-ONLY] split of a monorepo, low adoption, minimal release notes, and the Packagist entries are stubs. Pin the exact version in composer.json, avoid extending internal classes beyond FilesystemCachePool itself, and keep all instantiation behind your own PoolCache/provider so swapping is a config change.
Prefer the exit ramp when it appears. When you outgrow filesystem caching, symfony/cache's FilesystemAdapter implements the same PSR-6 interface with locking support and active maintenance, and Symfony\Component\Cache\Psr16Cache will expose this pool (or that one) as PSR-16. Because your call sites only touch CacheItemPoolInterface, that migration is a one-line binding change — which is the strongest argument for using this package in the first place.
Debugging recipe:
find storage/framework/cache/flysystem -type f | head -20
php -r 'var_dump(unserialize(file_get_contents($argv[1])));' path/to/file
Keys are hashed into filenames, so grep for the value, not the key, when hunting a mystery entry.
How can I help you explore Laravel packages today?