Cache¶
Namespace Keel\Cache. A dependency-free PSR-16 cache with three drivers, wired as
Psr\SimpleCache\CacheInterface; swap in symfony/cache or any PSR-16 implementation by aliasing
the interface yourself.
// config/cache.php
return static fn(Env $env, string $projectDir): CacheConfig => new CacheConfig(
driver: 'auto', // apcu when loaded, else file; or "apcu", "file", "array"
directory: $projectDir . '/var/cache/data',
prefix: 'myapp:',
defaultTtl: 3600, // seconds for set() without a TTL; null = forever
);
// config/services.php
return new ServicesConfig([CacheProvider::class]);
final class RatesService
{
public function __construct(private readonly CacheInterface $cache, private readonly ClientInterface $http) {}
public function rate(string $currency): float
{
$rate = $this->cache->get('rate.' . $currency);
if (!is_float($rate)) {
$rate = $this->fetch($currency);
$this->cache->set('rate.' . $currency, $rate, 600);
}
return $rate;
}
}
Drivers¶
| Driver | Shared by | Notes |
|---|---|---|
ApcuCache |
every php-fpm child or FrankenPHP thread of the server | fastest; cleared on restart; needs ext-apcu with apc.enable_cli=1 for console commands |
FileCache |
every worker on the host | one serialized file per entry, atomic writes, expired entries removed on read and by a probabilistic sweep (sweep() runs it on demand, and drops temporary files a crashed write left behind) |
ArrayCache |
the current process only | tests and per-worker memoization |
RedisCache (driver: 'redis') |
every worker on every host | ext-redis through RedisProvider (below); values serialized with serialize(), so keep the instance private |
MemcachedCache (driver: 'memcached') |
every worker on every host | ext-memcached, servers: ['host:11211']; keys over 250 bytes are hashed; clear() flushes the whole instance |
Keys follow PSR-16: up to 256 characters, none of {}()/\@:; a bad key throws
InvalidKeyException (a Psr\SimpleCache\InvalidArgumentException). TTLs are seconds or a
DateInterval; a non-positive TTL deletes. A stored null counts as a hit.
FileCache directory. Entries are unserialized with every class allowed, so whoever can
write to the directory can inject objects into your process. The driver creates a missing
directory with mode 0700 and refuses one that is a symlink, writable by everyone, or
group-writable while owned by another user (RuntimeException). Without directory the default
is a predictable path under the system temp dir: fine on a host that runs only your
application, but on a shared machine set directory to a private path such as
$projectDir . '/var/cache/data'.
Keel\Cache\Cache (an alias of the PSR interface) adds remember($key, $ttl, fn() => ...):
the cached value, or the closure's result stored under the key. A stored null is a hit.
Redis¶
RedisConfig in config/redis.php plus RedisProvider give the container a \Redis
connection (ext-redis, connected on first use, persistent across the requests of a worker), a
redis health check for /health, and, per use, the Keel storages that otherwise live in
APCu or process memory:
// config/redis.php
return static fn(Env $env): RedisConfig => RedisConfig::fromUrl(
$env->require('REDIS_URL'), // redis://:password@host:6379/2, rediss:// for TLS
prefix: 'myapp:',
use: ['sessions', 'counters', 'metrics'],
);
// or new RedisConfig(host: '127.0.0.1', port: 6379, auth: null, database: 0, prefix: 'myapp:', use: [...])
// config/cache.php
return new CacheConfig(driver: 'redis', prefix: 'myapp:');
use |
Registers | Replaces |
|---|---|---|
sessions |
Keel\Session\RedisStorage as SessionStorageInterface |
file or cookie sessions: shared by every node, expired by Redis, refreshed like file sessions |
counters |
Keel\RateLimit\RedisCounters as CounterStorage |
APCu rate-limit counters: one bucket per fleet; fails closed when Redis is unreachable |
metrics |
Keel\Metrics\RedisStorage as MetricsStorage |
APCu metrics: one hash for the fleet, so /metrics on any node reports all of it |
Every key starts with the prefix (myapp:cache:, myapp:sess:, myapp:rl:, myapp:metrics).
RedisProvider::connect() builds the connection; register RedisConfig before loading the
provider (configs are registered first, so config/redis.php is enough).
Output cache¶
#[OutputCache] serves a route's whole response from the cache: the handler runs once, later
GET and HEAD requests get the stored copy until it expires or one of its tags is invalidated.
It needs a PSR-16 CacheInterface (CacheProvider).
#[Get('/products/{id}')]
#[OutputCache(ttl: 300, tags: ['products', 'product:{id}'], varyByQuery: ['lang'], varyByHeaders: ['Accept-Language'])]
public function show(int $id): ProductView { /* ... */ }
#[Patch('/products/{id}')]
public function update(int $id, #[FromBody] ProductPatch $patch, OutputCacheStore $pages): ProductView
{
$product = $this->products->update($id, $patch);
$pages->invalidateTags('products', "product:$id");
return ProductView::from($product);
}
The key is the method, host and path, plus the query string (varyByQuery: true, the default;
false ignores it; a list keeps only those parameters), the listed request headers and, with
varyByPrincipal: true, the principal. Tags may name route placeholders (product:{id}).
invalidateTags() gives each tag a new version; a stored response remembers the versions it was
stored with and is stale once one changed, which works on any PSR-16 store without scanning keys.
Tag versions are stored without expiry, and one that was evicted reads as unknown, so its
responses become stale rather than fresh again.
Responses get X-Cache: HIT or MISS (OutputCacheConfig::$header, null for none) and, on a
hit, Age. Only 200 responses are stored; never those that set a cookie, say
Cache-Control: no-store or private, stream their body or exceed
OutputCacheConfig::$maxBodyBytes (1 MiB). Requests with an Authorization header bypass the
cache unless the route varies by principal and the principal is known by then: declare #[Auth]
before #[OutputCache]. Without it such requests bypass the cache rather than share one copy.
Unlike #[CacheControl], which tells clients and proxies what to keep, this cache saves the work
on the server.
Worker mode¶
ArrayCache is a per-worker cache: with four workers an entry is computed up to four times
and clear() reaches one of them. Use APCu or files for anything that must be consistent
across workers, and keep ArrayCache for data that is cheap to rebuild.
RateLimit and Metrics have their own storages with the same APCu/memory split; they do
not go through this cache.