Skip to content

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

bash
composer require jardisadapter/cache

GitHub: jardisAdapter/cache

Optional PHP extensions:

ExtensionFor
ext-redisCacheRedis (recommended for production)
ext-apcuCacheApcu (worker-scope caching)
ext-pdoCacheDatabase (persistent cache)

Basic Usage

Single-Layer Setup

php
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-aware

Multi-Layer Setup

The real value, layers from fast (top) to persistent (bottom):

php
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')?

  1. L1 (Memory) is queried → miss
  2. L2 (Redis) is queried → miss
  3. L3 (Database) is queried → hit!
  4. The value is automatically written back into L2 and L1 (Write-Through)
  5. 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

php
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

php
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

php
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).

php
// 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

php
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:

sql
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:

php
$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

php
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:

php
$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' keys

TTL (Time-to-Live)

php
// 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:

SituationBehavior
Redis unreachableget() → default, set()false
PDO errorget() → default, set()false
Empty/invalid keyInvalidArgumentException (PSR-16 compliant)
No layers configuredInternal 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, serialization

Read 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 compliant

API Reference

Cache (Orchestrator)

MethodSignatureDescription
getget(string $key, mixed $default = null): mixedReads from layer chain, Write-Through on hit
setset(string $key, mixed $value, null|int|DateInterval $ttl = null): boolWrites to all layers
deletedelete(string $key): boolDeletes from all layers
clearclear(): boolClears all layers (namespace-aware)
hashas(string $key): boolChecks layer chain
getMultiplegetMultiple(iterable $keys, mixed $default = null): iterableBulk read
setMultiplesetMultiple(iterable $values, null|int|DateInterval $ttl = null): boolBulk write
deleteMultipledeleteMultiple(iterable $keys): boolBulk delete
getLayersgetLayers(): array<int, CacheInterface>Introspection

CacheDatabase (Additional)

MethodSignatureDescription
cleanExpiredcleanExpired(): boolDelete expired entries
getConnectiongetConnection(): PDORetrieve PDO instance

CacheRedis (Additional)

MethodSignatureDescription
getConnectiongetConnection(): RedisRetrieve Redis instance

Complete Example

A typical setup for a Jardis application, with three layers and a scheduled cleanup:

php
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();