ark/filecache
ark/filecache provides a lightweight file-based cache driver for Laravel, storing cached data on disk without Redis or Memcached. It integrates with Laravel’s cache API, making it a simple, dependency-free option for small apps, local development, or shared hosting environments.
Install and confirm the wiring (5 minutes).
composer require ark/filecache
composer.json extra.laravel. On older or non-Laravel apps, register the provider manually in config/app.php and, if offered, an alias/facade.php artisan vendor:publish --tag=filecache-config (tag name varies — if it fails, run php artisan vendor:publish and pick the package from the list). Expect a config array with at minimum a storage path, a default TTL, and file/directory permissions.Psr\SimpleCache\CacheInterface) implementation — that interface is almost certainly what the package targets, and it tells you exactly which methods exist.Where to look first, in this order:
src/ — the store class. It's a small package; you can read the whole thing in ten minutes. This is the documentation.config/*.php — confirms the directory path, TTL default, and permission mode.tests/ — the most reliable usage examples, especially for edge cases like expired entries and key sanitisation.README — for the entry point (facade vs. container binding vs. constructor injection).First use case — verify with a throwaway script before touching application code:
$cache = app(\Ark\FileCache\...::class); // or the facade
$cache->set('smoke:test', ['ok' => true], 60);
var_dump($cache->get('smoke:test')); // expect the array back
sleep(61); // or set TTL to 2
var_dump($cache->get('smoke:test')); // expect null
Then ls -la storage/framework/cache/... (or whatever path is configured) and confirm a file appeared with the expected mtime. If no file appears, the problem is the path or permissions, not the API.
The natural first production use is local development and single-server deployments where you don't want Redis/Memcached: caching expensive query results, rendered view fragments, or third-party API responses.
Put it behind an interface, always. Even a one-line binding buys you the ability to swap to Redis later without touching call sites:
// AppServiceProvider
$this->app->bind(\Psr\SimpleCache\CacheInterface::class, fn ($app) => $app->make(YourFileCache::class));
Then type-hint Psr\SimpleCache\CacheInterface in your services. Given the package's maturity (see Gotchas), this is the single highest-value habit.
Cache-aside with an explicit helper. If the package exposes remember-style sugar, use it. If not, wrap it once and reuse:
function cached(string $key, int $ttl, Closure $producer) {
$cache = app(CacheInterface::class);
if (($hit = $cache->get($key)) !== null) {
return $hit;
}
$value = $producer();
$cache->set($key, $value, $ttl);
return $value;
}
Note the !== null — a cached false or 0 is a legitimate hit and a common source of "cache isn't working" bugs.
Key naming discipline. Filesystem caches usually map keys to filenames or hashes. Adopt a convention now:
$key = sprintf('v%d:%s:%s', 2, 'products', md5(json_encode($filters)));
/, :, \, or .. in a key raw.TTL strategy.
forever / no-expiry only for artefacts regenerated by a deploy step, never for request-time data — you have no reliable eviction pressure like Redis's LRU.filemtime + ttl, anything that touches the file (backup jobs, rsync -a, editors) can extend or destroy the lifetime.Deploy-time warmup command. This is where file caches shine — the cache survives across requests and can be built once per release:
// app/Console/Commands/WarmCache.php
public function handle() {
$this->call('cache:clear'); // or delete the directory
foreach (config('app.warmable') as $key => $producer) {
cached($key, 3600, fn () => ($producer)());
}
}
Wire it into your deploy script after migrations, before traffic flips. This eliminates the cold-cache stampede on the first requests.
Isolate by store, not by tag. File-based caches typically can't do tags or namespaced flush cheaply. Instead, register multiple stores pointing at different directories:
// config/cache.php
'stores' => [
'filecache' => ['driver' => 'filecache', 'path' => storage_path('framework/cache/data')],
'filecache:views' => ['driver' => 'filecache', 'path' => storage_path('framework/cache/views')],
],
Now flushing views never touches your API cache. Cache::store('filecache:views')->... is the access pattern. If a native Laravel driver isn't provided, construct the store class yourself with a per-path config array and bind it to a named singleton.
Stampede protection. Without Cache::lock, guard expensive rebuilds with a cheap sentinel write (setnx-equivalent) or an atomic tempnam + rename. Also add jitter to TTLs (e.g. $ttl + random_int(0, 30)) so a batch of entries written together doesn't all expire in the same second.
Testing.
protected function setUp(): void {
parent::setUp();
config(['cache.stores.filecache.path' => sys_get_temp_dir().'/fc-tests/'.uniqid()]);
// rebuild the store binding if it was resolved as a singleton
}
protected function tearDown(): void {
File::deleteDirectory(config('cache.stores.filecache.path'));
parent::tearDown();
}
Beware container singletons holding a stale path — re-resolve (app()->forgetInstance(...)) after changing config.
Decorator pattern for observability. Wrap the store in a class implementing the same interface that logs hit/miss and durations, then delegates. You get cache metrics without touching call sites, and it's a two-file change to remove later.
Permissions are the #1 failure mode. PHP-FPM runs as www-data, your deploy/CLI runs as deploy. The directory must be writable by both, or you get silent write failures and a cache that "never hits." Use a shared group with setgid (chmod 2775) or run artisan cache:* as the web user. Also confirm your configured permission mode is 0775/0664 rather than the sometimes-default 0777 — you don't want world-writable cache in a shared host.
Not shared across servers. A file cache on a load-balanced fleet gives each node its own view of the world. That's fine for idempotent, per-node caching; it's wrong for anything requiring consistency (rate limiting, one-time tokens, "has this job run?"). Avoid NFS-backed cache directories too — flock semantics are unreliable and latency will punish you.
Check which interface methods actually exist before relying on Laravel's full Repository API. Small file drivers frequently implement get/set/delete/has/clear and stop there. The classic silent traps:
Cache::tags(...) — likely unsupported, throwing BadMethodCallException.increment() / decrement() — often absent or non-atomic; do not use for counters.Cache::lock() — usually absent, since it depends on a shared atomic primitive.remember() — present in Laravel's repository wrapper but not in raw PSR-16; check what you're actually holding.Expired files may not be deleted on read. Many implementations lazily ignore expired entries but leave the file on disk. Without GC, the directory grows unbounded and you can exhaust inodes. Schedule a cleanup (Cache::store(...)->clear() on a nightly cadence is too blunt if the cache is warm; prefer targeting a versioned subdirectory). Verify empirically: set a 1-second TTL, wait 2 seconds, read, then ls the directory.
Filename limits. Keys longer than ~200 chars, or containing characters the sanitiser doesn't escape, can collide or throw. Hash the key. Related: check whether the implementation uses a fan-out scheme (two-level hashed subdirectories) — if not, thousands of files in one directory degrades lookup and ls performance on ext4.
cache:clear may not reach this store. If the package registers its own path outside Laravel's managed store list, php artisan cache:clear silently skips it. Test it. Have a documented rm -rf <path> fallback in your deploy docs, and make sure the deploy user owns the path.
Relative vs. absolute paths. Config values like 'path' => 'cache/foo' are resolved against the CWD, which differs between CLI and FPM. Always use storage_path(...) or base_path(...).
Serialization surprises. Storing an Eloquent model or a Closure will either throw or produce a corrupted read. Cache plain arrays and scalars. If the package serialises with serialize(), a class rename in a future deploy makes old entries fatal on read — another argument for versioned key prefixes and a clear on every deploy.
Debugging workflow when a get returns null:
find <path> -newermt '-1 hour' | head.serialize()d, unserialize(file_get_contents(...)) in Tinker to inspect.filemtime vs. now vs. your TTL; consider whether clock skew between CLI and FPM matters.Cache::store(...) names is a very common mistake).Vendor risk — treat this as a small internal utility, not a dependency. Six stars and zero dependents means: pin the exact version in composer.json, read the source before upgrading, and be prepared to vendor it via a path repository or a fork. The upside is that the API surface is tiny, so the replacement cost is near zero provided you've been injecting the PSR-16 interface rather than calling the concrete class everywhere. Write down the three or four methods you actually use; that list is your exit plan.
Extension points worth knowing. Most file-cache packages expose a serialiser or a "hasher" for keys as a constructor dependency — swapping in JSON serialisation (json_encode/json_decode) makes entries human-readable and debuggable in production, at the cost of losing object fidelity. That trade is usually worth it for operational sanity. Also check for a configurable clock or a now() injection point; if one exists, expiry is properly testable without sleep().
How can I help you explore Laravel packages today?