The framework provides a unified cache adapter system with three backends — Redis, APCu, and Memcached — all implementing the same PSR-16 CacheInterface. The ca() helper auto-selects the best available adapter, while rca(), aca(), and mca() provide direct access to specific backends when needed.
| Adapter | Helper | Scope | Pattern deletion | Requires |
|---|---|---|---|---|
| APCu | aca() |
Per-process | Yes | ext-apcu |
| Redis | rca() |
Shared across servers | Yes | Redis server |
| Memcached | mca() |
Shared across servers | No | Memcached server |
APCu stores data in the PHP process memory. No network round trip — reads and writes are as fast as a PHP array lookup. The trade-off is that data is local to the current PHP process and not visible to other processes or servers. In DEV_MODE, APCu is cleared on every request boot.
Redis is the primary shared cache. Data is visible across all application servers, survives PHP process restarts, and supports pattern-based key deletion and pub/sub. Required by the framework — see Redis & Pub/Sub for full Redis documentation.
Memcached is an alternative shared cache. Similar to Redis for basic get/set operations but without pattern deletion, pub/sub, sorted sets, or lists. Use when you already have Memcached in your stack and don't need Redis-specific features.
ca( string|null $cacheAdapter = null ): AbstractCacheAdapter
Returns the best available adapter. With no argument, returns APCu if available and enabled, Redis otherwise:
// Auto-select — APCu if available, Redis fallback
ca()->set( 'player:123:score', 4500, 3600 );
ca()->get( 'player:123:score' );
ca()->has( 'player:123:score' );
ca()->delete( 'player:123:score' );
Force a specific adapter using the class constants:
ca( AbstractCacheAdapter::REDIS_CACHE_ADAPTER )->set( 'key', $value );
ca( AbstractCacheAdapter::APCU_CACHE_ADAPTER )->set( 'key', $value );
ca( AbstractCacheAdapter::MEMCACHED_CACHE_ADAPTER )->set( 'key', $value );
ca() is the right default for most application cache use cases. Use specific adapters directly only when you need features that aren't available on all backends.
All three adapters implement the same CacheInterface:
get( string $key, mixed $default = null ): mixed
Returns the cached value or $default if the key doesn't exist or has expired:
$score = ca()->get( 'player:123:score' ); // null if missing
$score = ca()->get( 'player:123:score', 0 ); // 0 if missing
$data = ca()->get( 'config:features', '{}' ); // '{}' if missing
set( string $key, mixed $value, DateInterval|int|null $ttl = null ): bool
Stores a value with optional TTL in seconds. Only scalar values are accepted — arrays, objects, and other non-scalar types throw CacheValueException. Serialize before storing:
// Scalar values
ca()->set( 'player:123:level', 10 );
ca()->set( 'player:123:name', 'john_doe' );
ca()->set( 'feature:pvp_enabled', true );
// Non-scalar must be serialized first
ca()->set( 'player:123:profile', j_encode( $player->jsonSerialize() ), 3600 );
// With TTL as integer (seconds)
ca()->set( 'session:token', $token, 1800 ); // 30 minutes
// With TTL as DateInterval
ca()->set( 'temp:data', $data, new DateInterval( 'PT1H' ) );
// No expiry
ca()->set( 'config:map_size', 512, null );
Returns true if stored successfully, false otherwise.
has( string $key ): bool
Returns true if the key exists and hasn't expired:
if ( ca()->has( 'player:123:profile' ) === false )
// rebuild and cache
delete( string $key ): bool
Deletes a single key:
ca()->delete( 'player:123:score' );
clear(): bool
Wipes all keys in the cache. For Redis this flushes the entire database. For APCu it clears the entire APCu cache for the current process. Use with caution in production:
ca()->clear(); // clears everything
rca()->clear(); // Redis only
aca()->clear(); // APCu only
mca()->clear(); // Memcached only
app:cache:clear calls clear() on all available adapters.
getMultiple( iterable $keys, mixed $default = null ): iterable
Retrieves multiple values in one call. Returns an array keyed by the original key names:
$values = ca()->getMultiple( [ 'player:1:score', 'player:2:score', 'player:3:score' ] );
// [
// 'player:1:score' => 4500,
// 'player:2:score' => 3200,
// 'player:3:score' => null, ← missing key
// ]
// With default for missing keys
$values = ca()->getMultiple( $keys, 0 );
Note: getMultiple() is implemented as individual get() calls in a loop — it does not use a Redis MGET command. For high-performance bulk reads, use rc()->mget() directly.
setMultiple( iterable $values, DateInterval|int|null $ttl = null ): bool
Stores multiple key-value pairs with the same TTL:
ca()->setMultiple( [
'player:1:score' => 4500,
'player:2:score' => 3200,
'player:3:score' => 5100,
], 3600 );
All values must be scalar — throws CacheValueException otherwise.
deleteMultiple( iterable $keys ): bool
Deletes multiple keys:
ca()->deleteMultiple( [
'player:123:score',
'player:123:profile',
'player:123:inventory',
] );
deleteByKeyPattern( string $keyPattern ): bool
Deletes all keys matching a pattern. Available on Redis and APCu, not Memcached.
The pattern supports * as a wildcard at the start, end, or both ends of the pattern. A * in the middle is not supported and throws CacheKeyExceptionCache:
// Delete all keys starting with 'player:123:'
rca()->deleteByKeyPattern( 'player:123:*' );
// Delete all keys ending with ':score'
rca()->deleteByKeyPattern( '*:score' );
// Delete all keys containing 'player'
rca()->deleteByKeyPattern( '*player*' );
// Delete everything
rca()->deleteByKeyPattern( '*' );
// This throws CacheKeyExceptionCache — * in the middle
rca()->deleteByKeyPattern( 'player:*:score' );
Keys are matched against the unprefixed key name — the SERVER_PREFIX:APP_ENV: prefix is stripped automatically before matching.
Calling mca()->deleteByKeyPattern() always throws UnsupportedPlatformException. If you need pattern deletion, use rca() or aca().
There's no enforced key naming convention but consistent patterns make debugging and pattern deletion much easier. The project uses colon-separated namespacing:
// Entity-based keys
'player:{id}:profile'
'player:{id}:inventory'
'guild:{id}:members'
// Feature-based keys
'leaderboard:global:top100'
'map:{id}:state'
// Framework internal keys (for reference — don't use these prefixes)
'cache:routes_list'
'cached_template_class_{className}'
'translated_strings:{locale}'
'parsed_url:{method}:{hash}'
Keep keys short but descriptive. The full stored key includes the SERVER_PREFIX:APP_ENV: prefix — a key stored as player:123:score is stored in Redis as my-app:dev:player:123:score.
| Data type | Suggested TTL | Reasoning |
|---|---|---|
| Route list | None | Only changes on deployment |
| Template classes | None | Only changes on deployment |
| Translation strings | None | Only changes on deployment |
| Player profile | 3600 (1 hour) | Changes infrequently |
| Leaderboard | 300 (5 min) | Acceptable staleness |
| Session data | 1800 (30 min) | Active session window |
| Temporary computation | 60–300 | Short-lived results |
| Rate limit counters | 60 | Per-minute window |
| Redirect data | 300 | Safety net (delete on read) |
Use null TTL only for data that should persist until explicitly cleared — route lists, compiled templates, configuration. Never use null for player data or anything user-specific.
Entities must be serialized before caching since cache adapters only accept scalar values:
// Store
ca()->set(
sprintf( 'player:%d:profile', $player->getId() ),
j_encode( $player->jsonSerialize() ),
3600
);
// Retrieve
$cached = ca()->get( sprintf( 'player:%d:profile', $playerId ) );
if ( $cached !== null ) {
$data = j_decode( $cached, true );
// use $data array directly or hydrate into an entity
}
For full entity hydration from cache, fetching from the database and letting Doctrine's second-level cache handle it is often cleaner than manual serialization. Use manual cache for computed values, aggregated data, and expensive query results.
APCu as L1 and Redis as L2 gives you the fastest possible reads for hot data — APCu hits are in-process with no network overhead, Redis is the fallback for data not yet in APCu:
function getPlayerScore( int $playerId ): int
{
$key = sprintf( 'player:%d:score', $playerId );
// L1 — APCu (in-process, no network)
if ( function_exists( 'apcu_enabled' ) && apcu_enabled() ) {
$score = aca()->get( $key );
if ( $score !== null )
return (int) $score;
}
// L2 — Redis (shared, network)
$score = rca()->get( $key );
if ( $score !== null ) {
// Populate L1 for future requests
if ( function_exists( 'apcu_enabled' ) && apcu_enabled() )
aca()->set( $key, $score, 300 );
return (int) $score;
}
// Miss — load from database
$player = Player::find( $playerId );
$score = $player->getScore();
// Populate both levels
rca()->set( $key, $score, 3600 );
if ( function_exists( 'apcu_enabled' ) && apcu_enabled() )
aca()->set( $key, $score, 300 );
return $score;
}
ca() auto-selects APCu when available, which naturally gives you L1 access. For L2 fallback you need to handle it manually as above, or simply use ca() and accept that on a cache miss it goes straight to the database.
The most reliable pattern — when an entity changes, delete its cache keys immediately:
// In a lifecycle callback or service method after persist
rca()->deleteByKeyPattern( sprintf( 'player:%d:*', $player->getId() ) );
AbstractEntity does this automatically for query builder cache on every postPersist, postUpdate, and postRemove via clearQueryBuilderCache().
For data where some staleness is acceptable — leaderboards, aggregate statistics, map states. Set a TTL and let it expire naturally. Simpler but means brief windows of stale data.
Used by the redirect data system — delete the cache entry immediately after reading it. Appropriate for data with exactly one consumer:
$data = ca()->get( $key );
ca()->delete( $key );
// use $data
All adapters extend AbstractCacheAdapter which provides getMultiple(), setMultiple(), and deleteMultiple() as default implementations built on the individual get(), set(), and delete() methods. Each concrete adapter only needs to implement:
get()set()delete()clear()has()deleteByKeyPattern()The singleton pattern is enforced in AbstractCacheAdapter — all three adapter classes share the same $instances static array, and getInstance() is the only way to get an adapter instance. Direct construction is blocked by a private constructor.
Storing non-scalar values directly — arrays, objects, and null are not accepted by set(). Always j_encode() before storing and j_decode() after retrieving:
// Wrong — throws CacheValueException
ca()->set( 'data', [ 'key' => 'value' ] );
// Correct
ca()->set( 'data', j_encode( [ 'key' => 'value' ] ) );
Using mca()->deleteByKeyPattern() — always throws UnsupportedPlatformException. Memcached doesn't support key enumeration. Use rca()->deleteByKeyPattern() or mca()->clear() to flush everything.
Assuming ca() always returns the same adapter — ca() returns APCu if available, Redis otherwise. On a server without APCu, ca() returns Redis. On a server with APCu, it returns APCu. If you store something via ca() and read via rca(), you may get a cache miss if the data was stored in APCu. Use the same adapter consistently for the same key.
Not serializing before storing — related to the non-scalar point above, but specifically: true and false are scalars and are accepted, but get() may return them as strings from Redis ('1' and ''). Cast explicitly when reading boolean flags:
ca()->set( 'feature:enabled', true );
$enabled = (bool) ca()->get( 'feature:enabled' );
Pattern deletion with * in the middle — deleteByKeyPattern( 'player:*:score' ) throws CacheKeyExceptionCache. Only leading, trailing, or wrapping wildcards are supported. Restructure your key naming so related keys share a common prefix or suffix instead.
Forgetting key prefix in redis-cli — all Redis keys are stored with SERVER_PREFIX:APP_ENV: prepended. A key stored as player:123:score appears in redis-cli as my-app:dev:player:123:score. Use KEYS *player:123:score to find it.