marko/cache-file
File-based cache driver --- persists cached data to disk with automatic expiration and atomic writes. Stores serialized cache entries as individual files, using atomic writes (temp file + rename) to prevent corruption. Expired entries are cleaned up on read. No external services required --- works anywhere PHP can write to disk.
Implements CacheInterface from marko/cache.
Installation
Section titled “Installation”composer require marko/cache-fileThis automatically installs marko/cache and marko/encryption. Every cache file is HMAC-signed with encryption.key, so a non-empty key is required. Without one, reads and writes throw Marko\Cache\Exceptions\TamperedCacheValueException.
use Marko\Config\Env;
return [ 'key' => Env::string('ENCRYPTION_KEY', ''),];Configuration
Section titled “Configuration”Set the cache driver to file in your config:
return [ 'driver' => 'file', 'default_ttl' => 3600, 'path' => 'storage/cache',];The path directory is created automatically if it does not exist. A relative path resolves against the project root (ProjectPaths::resolve()), not the current working directory, so it lands in the same place under the CLI and under PHP-FPM, CGI or mod_php, which chdir() into public/. Absolute paths, including Windows C:\... paths, are used as configured.
Once configured, inject CacheInterface as usual --- the file driver is used automatically:
use Marko\Cache\Contracts\CacheInterface;
class SettingsService{ public function __construct( private CacheInterface $cache, ) {}
public function getAll(): array { $key = 'settings.all';
if ($this->cache->has($key)) { return $this->cache->get($key); }
$settings = $this->loadFromDatabase(); $this->cache->set($key, $settings, ttl: 7200);
return $settings; }}When to Use
Section titled “When to Use”- Default choice for most applications
- No external dependencies (Redis, Memcached, etc.)
- Data persists across requests and restarts
- Suitable for single-server deployments
For multi-server deployments or high-throughput caching, use marko/cache-redis.
Atomic Locks
Section titled “Atomic Locks”The package also binds LockStoreInterface to Marko\Cache\File\Lock\FileLockStore, so atomic locks work through LockFactoryInterface with no extra setup. Its scope is LockScope::Host: it excludes processes on the same host only. Workers on two servers each get the same lock, so use marko/cache-redis when workers run on more than one host.
A lock is a signed record (owner and expiry) in the cache directory, claimed and freed under the same brief flock() that increment() and add() use. The flock() is never held for the lock’s lifetime, so a lock outlives the request that took it and restore() plus release() work from any process on the host. Lock records expire on whole seconds, like cache entries, and marko cache:clear removes them along with the cache.
API Reference
Section titled “API Reference”Implements all methods from CacheInterface. See marko/cache for the full contract.
Key Methods
Section titled “Key Methods”| Method | Description |
|---|---|
get(string $key, mixed $default = null): mixed | Retrieve a value, returning $default on miss or expiration |
set(string $key, mixed $value, ?int $ttl = null): bool | Store a value with optional TTL (falls back to default_ttl) |
has(string $key): bool | Check if a non-expired entry exists |
delete(string $key): bool | Remove a single entry |
clear(): bool | Remove all cached entries |
getItem(string $key): CacheItemInterface | Get a CacheItem with hit/miss status and expiration metadata |
getMultiple(array $keys, mixed $default = null): iterable | Retrieve multiple values at once |
setMultiple(array $values, ?int $ttl = null): bool | Store multiple key-value pairs at once |
deleteMultiple(array $keys): bool | Remove multiple entries at once |
increment(string $key, int $ttl): int | Atomically increment an integer counter; TTL applied only on first increment |
add(string $key, mixed $value, ?int $ttl = null): bool | Atomically store a value only if the key is absent or expired; false when an unexpired value exists |
remember(string $key, ?int $ttl, callable $callback): mixed | Return the cached value, or compute and store it on a miss (delegates to CacheOperations) |
pull(string $key, mixed $default = null): mixed | Get a value and delete it (delegates to CacheOperations) |
forever(string $key, mixed $value): bool | Store a value with no expiry (delegates to CacheOperations) |
Storage Details
Section titled “Storage Details”- Each cache key is hashed with
xxh128and stored as a.cachefile in the configured path. - Each file holds an HMAC-SHA256 envelope (
{hmac}.{serialized-entry}) signed byCacheValueSigner. The HMAC is checked withhash_equals()beforeunserialize()runs, so a file planted in the cache directory can’t inject objects. - The HMAC is bound to the cache key, so a file copied over another key’s file does not verify.
- A file that is unsigned, malformed, tampered with, copied from another key, or signed with a different key is treated as a miss and deleted;
increment()restarts such a counter at1. Entries signed by an older release (before signing, or before HKDF subkeys and key binding) don’t verify, so upgrading (or rotatingencryption.key) empties the file cache. - Writes use a temp file with
LOCK_EXfollowed by an atomicrename()to prevent corruption. increment(),add()andFileLockStoreshare one read-modify-write path (Marko\Cache\File\Storage\FileEntryStorage::mutateLocked()): they hold an exclusiveflock()on the entry’s file from the read to the write, so concurrent processes run one after another and exactly oneadd()for a key succeeds. If the file was deleted or replaced while a process waited for the lock, it reopens the new file and locks again, so a write never lands in an unlinked file.- The cache directory is created on the first write. Later writes check
is_dir()and never callmkdir()again. - A
nullTTL falls back todefault_ttlfrom config. A TTL of0or less means the entry never expires. - Expired entries are deleted lazily --- on the next
get(),has(), orgetItem()call for that key. - Expiry and
created_attimestamps come from the PSR-20ClockInterface(marko/clock), nottime(). Construct the driver with aFakeClockto test expiry without sleeping (see Expiry and the Clock).
Errors
Section titled “Errors”set(), setMultiple(), increment() and add() throw Marko\Cache\File\Exceptions\FileCacheException (a CacheException) when the disk refuses the write. They don’t return false, and increment() never fails open by returning 1: a rate limiter on a full or unwritable cache directory rejects requests loudly instead of allowing all of them. The exception context includes the operating system’s reason, such as Permission denied, No space left on device or Not a directory.
| Method | When thrown |
|---|---|
FileCacheException::directoryNotCreatable($path, $reason) | The configured cache.path doesn’t exist and can’t be created |
FileCacheException::writeFailed($path, $reason) | The temp file can’t be written or can’t be renamed onto the cache entry (the temp file is removed), or increment(), add() or a lock acquire can’t write the new entry |
FileCacheException::openFailed($path, $reason) | increment(), add() or a FileLockStore call can’t open the entry’s file |
FileCacheException::lockFailed($path, $reason) | increment(), add() or a FileLockStore call can’t take an exclusive flock() on the entry’s file |
FileCacheException::deleteFailed($path, $reason) | A lock release can’t delete the lock’s file |
Every read and write path (get(), has(), getItem(), getMultiple(), set(), setMultiple(), increment(), add()) throws Marko\Cache\Exceptions\TamperedCacheValueException::emptySigningKey() when encryption.key is empty. A tampered entry never throws: it is a miss.