Cache
Multi-layer caching with automatic Write-Through: from in-memory to database.
Introduction
Caching in enterprise applications is rarely as simple as "install Redis and you're done". Different data has different requirements: session data must be blazing fast, configuration values should survive a server restart, and some values need both: fast access with a persistent fallback.
jardisadapter/cache solves this with a multi-layer approach. Instead of choosing a single cache type, you stack multiple layers on top of each other. The fastest is queried first; on a miss, the request automatically travels down, and a hit is automatically written back into the upper layers. The result: optimal performance with maximum reliability.
- PSR-16 compatible — standard interface, interchangeable with any PSR-16 implementation
- 5 adapters — Memory, APCu, Redis, Database and Null (Graceful Degradation)
- Automatic Write-Through — cache miss in L1? The value from L3 is automatically backfilled into L1 and L2
- Namespace isolation — multiple applications or BoundedContexts share a cache server without conflicts
- Graceful Degradation — a failed layer does not break the application
Installation
composer require jardisadapter/cacheGitHub: jardisAdapter/cache
Optional PHP extensions:
| Extension | For |
|---|---|
ext-redis | CacheRedis (recommended for production) |
ext-apcu | CacheApcu (worker-scope caching) |
ext-pdo | CacheDatabase (persistent cache) |
Basic Usage
Single-Layer Setup
use JardisAdapter\Cache\Cache;
use JardisAdapter\Cache\Adapter\CacheRedis;
$redis = new Redis();
$redis->connect('127.0.0.1', 6379);
$cache = new Cache([
new CacheRedis($redis, 'myapp'),
]);
// PSR-16 API
$cache->set('user:42', $userData, 300); // 5 minutes TTL
$user = $cache->get('user:42');
$cache->delete('user:42');
$cache->has('user:42'); // bool
$cache->clear(); // namespace-awareMulti-Layer Setup
The real value, layers from fast (top) to persistent (bottom):
use JardisAdapter\Cache\Cache;
use JardisAdapter\Cache\Adapter\CacheMemory;
use JardisAdapter\Cache\Adapter\CacheRedis;
use JardisAdapter\Cache\Adapter\CacheDatabase;
$cache = new Cache([
new CacheMemory('orders'), // L1: PHP array, request-scoped
new CacheRedis($redis, 'orders'), // L2: Redis, distributed
new CacheDatabase($pdo, namespace: 'orders'), // L3: Database, persistent
]);What happens on $cache->get('order:42')?
- L1 (Memory) is queried → miss
- L2 (Redis) is queried → miss
- L3 (Database) is queried → hit!
- The value is automatically written back into L2 and L1 (Write-Through)
- Next access: L1 delivers immediately
What happens on $cache->set('order:42', $data, 600)?
The value is written into all three layers simultaneously. delete() and clear() work analogously: all layers remain consistent.
Adapters in Detail
CacheMemory — Request Scope
use JardisAdapter\Cache\Adapter\CacheMemory;
$cache = new CacheMemory('myapp');PHP array in working memory. Lives only for the duration of the current request. No network overhead, no serialization: the fastest option. Ideal as L1 so frequently read values don't hit Redis or the database on every access.
CacheApcu — Worker Scope
use JardisAdapter\Cache\Adapter\CacheApcu;
$cache = new CacheApcu('myapp');APCu uses the PHP worker's shared memory. Values survive between requests on the same worker: faster than Redis, but not distributed across multiple servers. Ideal as an intermediate layer between Memory and Redis.
clear() without Namespace
clear() without a namespace empties the entire APCu cache of all applications on this worker. Always set a namespace.
CacheRedis — Distributed Cache
use JardisAdapter\Cache\Adapter\CacheRedis;
$redis = new Redis();
$redis->connect('127.0.0.1', 6379);
$cache = new CacheRedis($redis, 'myapp');The standard for distributed caching. The Redis instance is injected: no internal connection management. This allows sharing the connection (e.g. with the messaging system).
// Retrieve the Redis instance (e.g. for connection sharing)
$sharedRedis = $cache->getConnection();clear() with namespace uses SCAN in batches of 100 keys: production-safe, does not block Redis.
CacheDatabase — Persistent Cache
use JardisAdapter\Cache\Adapter\CacheDatabase;
$cache = new CacheDatabase(
pdo: $pdo,
cacheTable: 'cache', // Table name (default: 'cache')
cacheKeyField: 'cache_key', // Key column
cacheValueField: 'cache_value', // Value column
cacheExpiresAt: 'expires_at', // TTL column
namespace: 'myapp',
);PDO-based cache for long-lived values that should survive even a Redis restart. All field and table names are configurable.
Required schema:
CREATE TABLE cache (
cache_key TEXT PRIMARY KEY,
cache_value TEXT NOT NULL,
expires_at INTEGER
);
CREATE INDEX idx_cache_expires_at ON cache(expires_at);Cleaning up expired entries:
$cache->cleanExpired(); // DELETE WHERE expires_at <= NOW()cleanExpired() must be called explicitly (e.g. via cron job). There is no automatic background process.
CacheNull — Graceful Degradation
use JardisAdapter\Cache\Adapter\CacheNull;
$cache = new CacheNull();Null Object Pattern: all operations are no-ops. get() always returns the default, set() always returns true. Used internally by the Cache orchestrator when no layers are passed. Useful for tests or when caching should be optional.
Namespace Isolation
Each adapter accepts a namespace string. Internally, the cache key is hashed as namespace + sha256(originalKey). This allows multiple applications or BoundedContexts to share the same cache server:
$salesCache = new Cache([new CacheRedis($redis, 'sales')]);
$billingCache = new Cache([new CacheRedis($redis, 'billing')]);
$salesCache->set('order:1', $salesOrder);
$billingCache->set('order:1', $billingOrder);
// No conflict — different namespaces, different keys
$salesCache->clear(); // Deletes only 'sales' keysTTL (Time-to-Live)
// Seconds
$cache->set('key', $value, 300); // 5 minutes
// DateInterval
$cache->set('key', $value, new DateInterval('PT1H')); // 1 hour
// No expiry
$cache->set('key', $value); // Permanent (until clear/delete)
// Immediately expired (= delete)
$cache->set('key', $value, 0); // Treated as delete()TTL and Write-Through
During automatic Write-Through (L3 hit → L1 backfill), no TTL is propagated. The value in L1 lives without an expiry time. For CacheMemory this is unproblematic (request scope); for APCu, native eviction applies.
Error Handling
The package follows the Graceful Degradation principle:
| Situation | Behavior |
|---|---|
| Redis unreachable | get() → default, set() → false |
| PDO error | get() → default, set() → false |
| Empty/invalid key | InvalidArgumentException (PSR-16 compliant) |
| No layers configured | Internal CacheNull — application keeps running |
A failed cache layer returns defaults instead of interrupting the application. In a multi-layer setup, the application remains functional as long as at least one layer responds.
Architecture
The package follows the Closure-Orchestrator-Pattern with Chain of Responsibility:
Cache ← Orchestrator (PSR-16 API)
├── CacheMemory ← L1: PHP array, request-scoped
├── CacheApcu ← L2: Shared memory, worker-scoped
├── CacheRedis ← L3: Distributed cache
└── CacheDatabase ← L4: Persistent cache
↑
AbstractCache ← Common base: hashing, TTL, serializationRead strategy: L1 → L2 → L3 → L4, stops at first hit, backfills automatically.
Write strategy: Writes to all layers. No short-circuit: even if L1 fails, L2..L4 are written to.
Directory Structure
src/
├── Cache.php ← Orchestrator
├── Adapter/
│ ├── AbstractCache.php ← Base: hashing, TTL, encoding
│ ├── CacheNull.php ← Null Object
│ ├── CacheMemory.php ← In-Memory
│ ├── CacheApcu.php ← APCu
│ ├── CacheRedis.php ← Redis
│ └── CacheDatabase.php ← PDO
└── Exception/
└── InvalidArgumentException.php ← PSR-16 compliantAPI Reference
Cache (Orchestrator)
| Method | Signature | Description |
|---|---|---|
get | get(string $key, mixed $default = null): mixed | Reads from layer chain, Write-Through on hit |
set | set(string $key, mixed $value, null|int|DateInterval $ttl = null): bool | Writes to all layers |
delete | delete(string $key): bool | Deletes from all layers |
clear | clear(): bool | Clears all layers (namespace-aware) |
has | has(string $key): bool | Checks layer chain |
getMultiple | getMultiple(iterable $keys, mixed $default = null): iterable | Bulk read |
setMultiple | setMultiple(iterable $values, null|int|DateInterval $ttl = null): bool | Bulk write |
deleteMultiple | deleteMultiple(iterable $keys): bool | Bulk delete |
getLayers | getLayers(): array<int, CacheInterface> | Introspection |
CacheDatabase (Additional)
| Method | Signature | Description |
|---|---|---|
cleanExpired | cleanExpired(): bool | Delete expired entries |
getConnection | getConnection(): PDO | Retrieve PDO instance |
CacheRedis (Additional)
| Method | Signature | Description |
|---|---|---|
getConnection | getConnection(): Redis | Retrieve Redis instance |
Complete Example
A typical setup for a Jardis application, with three layers and a scheduled cleanup:
use JardisAdapter\Cache\Cache;
use JardisAdapter\Cache\Adapter\CacheMemory;
use JardisAdapter\Cache\Adapter\CacheRedis;
use JardisAdapter\Cache\Adapter\CacheDatabase;
// Redis connection
$redis = new Redis();
$redis->connect(getenv('REDIS_HOST') ?: '127.0.0.1', 6379);
// Multi-layer cache
$cache = new Cache([
new CacheMemory('shop'),
new CacheRedis($redis, 'shop'),
new CacheDatabase($pdo, namespace: 'shop'),
]);
// Write: lands in all three layers
$cache->set('product:42', [
'name' => 'Widget',
'price' => 9.99,
'stock' => 142,
], 3600); // 1 hour TTL
// Read: L1 (Memory) delivers immediately
$product = $cache->get('product:42');
// After server restart: L1 is empty, L2 (Redis) delivers
// → automatic Write-Through backfills L1
// Bulk operations
$cache->setMultiple([
'config:currency' => 'EUR',
'config:locale' => 'en_US',
]);
$configs = $cache->getMultiple(['config:currency', 'config:locale']);
// Cleanup (via cron)
$dbCache = new CacheDatabase($pdo, namespace: 'shop');
$dbCache->cleanExpired();