Repository navigation
Caching
The Machinjiri Caching System is a PSR-inspired caching layer that lets you store expensive computation results, database queries, or API responses so they can be retrieved instantly on subsequent requests.
It is designed around three principles:
-
Uniform API — Every driver implements the same
CacheStorecontract. You can swap drivers without changing application code. - Composable features — Serialization, compression, tagging, metrics, and stampede protection are orthogonal layers that can be enabled independently.
-
Progressive complexity — A beginner can call
$cache->set('key', $value)and be productive in a minute. Advanced users can wire up tagged invalidation with per-table TTL strategies and Redis-backed distributed locks.
| Feature | Description |
|---|---|
| Multiple Drivers | Array (in-memory), File (filesystem), Redis (distributed). |
| Unified API | Every store implements CacheStore. |
| TTL Support | Per-item time-to-live; null = no expiry. |
| Tagged Cache | Invalidate groups of keys at once via version bumping. |
| Stampede Protection | Prevents cache stampedes ("thundering herd") with locks. |
| Serialization | Pluggable serializers (JSON included). |
| Compression | Optional zlib/snappy/lz4 compression layer. |
| Eviction Policies | LRU and LFU for the ArrayStore; size-based eviction for FileStore. |
| Metrics | Hit/miss counts, hit rate, writes, per-table invalidations, latency, result sizes. |
| Per-Table Strategies | Map tables to named TTL strategies. |
| Atomic Counters |
increment / decrement for counters and rate limits. |
| Bulk Operations |
getMultiple, setMultiple, deleteMultiple. |
| Custom Exceptions |
CacheException extends the framework MachinjiriException. |
The caching system lives inside the Machinjiri framework namespace:
Mlangeni\Machinjiri\Core\Artisans\Caching
Ensure your autoloader maps Mlangeni\Machinjiri\ to the framework source directory. For Redis support, the RedisAdapter (Predis-based) must be installed.
The CacheManager is the entry point. It lazily resolves stores from configuration.
use Mlangeni\Machinjiri\Core\Artisans\Caching\CacheManager;
// Create a manager with default config
$cache = new CacheManager();
// Store a value (uses default TTL = 3600 seconds)
$cache->set('user:42', ['name' => 'Ada', 'role' => 'admin']);
// Retrieve it
$user = $cache->get('user:42'); // array
$missing = $cache->get('nope', 'N/A'); // 'N/A' (default)
// Delete
$cache->delete('user:42');
// Clear everything in the active store
$cache->clear();The remember method is the most useful shortcut — it returns the cached value or executes the callback, stores the result, and returns it:
$report = $cache->remember('monthly-report', function () use ($db) {
return $db->query('SELECT * FROM reports WHERE month = ?', [date('m')]);
}, 600); // cache for 10 minutes$cache->store('file')->set('key', 'value', 300);
$cache->store('redis')->set('key', 'value', 300);The CacheManager accepts a configuration array merged over sensible defaults:
$config = [
'default' => 'redis', // default store name
'stores' => [
'array' => [
'driver' => 'array',
'max_items' => 1000,
'eviction' => 'lru', // or 'lfu'
],
'file' => [
'driver' => 'file',
'path' => '/var/cache/myapp',
'max_files' => 5000,
'file_perm' => 0644,
],
'redis' => [
'driver' => 'redis',
'host' => '127.0.0.1',
'port' => 6379,
'password' => null,
'database' => 0,
'prefix' => 'cache:',
],
],
'serializer' => 'json',
'compression' => false,
'compression_algorithm' => 'zlib', // zlib | snappy | lz4
'prefix' => 'app',
'default_ttl' => 3600,
'stampede_protection' => true,
'circuit_breaker' => [
'enabled' => false,
'failures_threshold'=> 5,
'timeout' => 30,
],
'table_strategies' => [ // table => strategy name
'users' => 'short',
'reports' => 'long',
],
'strategy_ttls' => [ // strategy => seconds
'default' => 3600,
'short' => 300,
'long' => 7200,
],
];| Key | Purpose |
|---|---|
default |
Which store $cache->store() returns when no name is given. |
stores |
Driver definitions (see Driver Reference). |
serializer |
Serializer to use (json is the only built-in). |
compression |
Master switch for compression. |
compression_algorithm |
zlib, snappy, or lz4. |
prefix |
Logical application prefix. |
default_ttl |
TTL used when set()/remember() gets null. |
stampede_protection |
Enables the lock-based StampedeProtector. |
circuit_breaker |
Reserved for fail-fast behavior when a backend is down. |
table_strategies |
Maps a table name to a strategy name. |
strategy_ttls |
Maps a strategy name to a TTL in seconds. |
A store is a concrete backend. Every store implements CacheStore:
interface CacheStore
{
public function get(string $key, mixed $default = null): mixed;
public function set(string $key, mixed $value, ?int $ttl = null): bool;
public function delete(string $key): bool;
public function clear(): bool;
public function has(string $key): bool;
public function increment(string $key, int $value = 1): int|false;
public function decrement(string $key, int $value = 1): int|false;
public function getMultiple(array $keys, mixed $default = null): array;
public function setMultiple(array $values, ?int $ttl = null): bool;
public function deleteMultiple(array $keys): bool;
public function getStoreName(): string;
}Note:
CacheManager::has()is currently an alias ofget()(seeCacheManager.php). Use the store directly ($cache->store()->has($key)) for a strict boolean check.
The CacheManager is a factory + facade:
-
Factory: It lazily resolves and caches store instances in
$this->stores. -
Facade: It forwards
get/set/delete/clear/incrementto the default store. -
Extras: It holds the shared
CacheMetricsandStampedeProtector, and exposestags()for tagged caching.
$cache->store(); // default store
$cache->store('file'); // named store
$cache->getMetrics(); // shared metrics object
$cache->tags(['users']); // TaggedCache wrapperCacheItem is the in-memory record used by ArrayStore. It stores the value, an optional absolute expiry timestamp, a hit counter, and the last access time.
$item = new CacheItem('value', 60); // expires in 60s
$item->isExpired(); // false
$item->hit(); // hitCount = 1, lastAccess updated-
$ttl === nullmeans no expiry (expiresAtstaysnull). -
hit()is what powers LFU eviction (fewest hits evicted first).
Values must be serialized before they hit disk/Redis. The default is JsonSerializer:
$s = new JsonSerializer();
$s->serialize(['a' => 1]); // '{"a":1}'
$s->unserialize('{"a":1}'); // ['a' => 1]Compression is applied after serialization and reversed before deserialization:
serialize(value) → compress(data) → store
store → uncompress(data) → unserialize(data) → value
ArrayStore currently stores raw PHP values (no serialization needed for in-memory), but the serializer/compressor are injected for symmetry and future use.
CacheMetrics is a shared statistics collector. All built-in stores record hits, misses, and writes on it.
$metrics = $cache->getMetrics();
$metrics->getHitRate(); // 0.0 – 1.0
$metrics->getStats(); // structured arraygetStats() returns:
[
'hits' => 120,
'misses' => 30,
'writes' => 80,
'hit_rate' => 0.8,
'avg_key_generation_time' => 0.0004,
'avg_result_size_bytes' => 512.5,
'invalidations_by_table' => ['users' => 3, 'reports' => 1],
]Tagged caching lets you group keys and invalidate them together. Instead of deleting every key (impossible on many backends), it uses version bumping:
- Each tag has a version stored under
tag_version:<tag>(default1). - A cache key is built as
tags:<sha1 of tag:version pairs>:<original key>. - Calling
clear()increments each tag's version. All old keys become unreachable (orphaned) and will be garbage-collected later.
$cache->tags(['users', 'active'])->set('list', $users, 600);
// Later: invalidate everything tagged 'users'
$cache->tags(['users'])->clear();Key construction example:
tags:8f14e45fceea167a5a36dedd4bea2543:user:42
A cache stampede occurs when a hot key expires and thousands of concurrent requests all try to recompute it at once. StampedeProtector uses a simple lock:
- Check the cache. If hit → return.
- Try to acquire
lock:<key>. - Winner: compute, store, release lock, return.
- Losers: poll up to 5 times (50 ms apart). If the winner populates the cache, they return that value. Otherwise they fall back to computing.
$value = $cache->remember('expensive', fn () => doHeavyWork(), 300);
// Automatically uses StampedeProtector when enabled
⚠️ Important: The built-inacquireLock()uses an in-process array, so it only protects against concurrency within a single PHP process. For true multi-process/distributed protection, you must back the lock with RedisSET NX(see Extending the System).
The manager can map a table to a named TTL strategy — useful when you invalidate per-table in an ORM layer.
$cache->getTableStrategy('users'); // 'short'
$cache->getStrategyTtl('short'); // 300
$ttl = $cache->getStrategyTtl(
$cache->getTableStrategy('users') ?? 'default'
);$cache->increment('page:views'); // 1
$cache->increment('page:views', 5); // 6
$cache->store()->decrement('page:views'); // 5
RedisStoreuses nativeINCRBY/DECRBY(atomic).ArrayStore/FileStoredo a read-modify-write, which is not atomic across processes.
$cache->store()->setMultiple([
'a' => 1,
'b' => 2,
], 300);
$values = $cache->store()->getMultiple(['a', 'b', 'c'], 'missing');
$cache->store()->deleteMultiple(['a', 'b']);RedisStore pipelines setMultiple for efficiency.
Implement CacheStore and register it by extending CacheManager::resolveStore():
class MemcachedStore implements CacheStore
{
// ... implement all interface methods
public function getStoreName(): string { return 'memcached'; }
}Then extend the manager:
class MyCacheManager extends CacheManager
{
protected function resolveStore(string $name): CacheStore
{
if (($this->config['stores'][$name]['driver'] ?? null) === 'memcached') {
return new MemcachedStore(/* ... */);
}
return parent::resolveStore($name);
}
}Implement SerializerInterface:
class MsgPackSerializer implements SerializerInterface
{
public function serialize(mixed $value): string
{
return msgpack_pack($value);
}
public function unserialize(string $data): mixed
{
return msgpack_unpack($data);
}
}Then extend CacheManager::getSerializer() to return it based on $this->config['serializer'].
Best for: single-request caches, unit tests, CLI scripts.
| Aspect | Detail |
|---|---|
| Storage | PHP array of CacheItem objects. |
| Eviction |
lru (default) or lfu. |
| Limit |
max_items (default 1000). |
| TTL | Per-item via CacheItem::isExpired(). |
| Persistence | None — gone at end of request. |
$store = new ArrayStore(1000, 'lru', new JsonSerializer(), null, new CacheMetrics());Behavior notes
-
get()on an expired key deletes it and records a miss. -
get()updates the LRU order and incrementshitCount. -
evictIfNeeded()triggers only whencount >= maxItemsbefore inserting. -
increment()on a non-numeric value returnsfalse.
Best for: medium-traffic apps without Redis; persistent across requests.
| Aspect | Detail |
|---|---|
| Storage | One .cache file per key, named sha1(key). |
| Format |
serialize(['expires' => ?int, 'value' => string]). |
| Eviction | Deletes oldest files (by lastModified) when count exceeds max_files. |
| Atomicity | Not guaranteed; write() is not atomic-rename by default. |
$store = new FileStore($filesystem, 5000, $serializer, $compressor, $metrics);Behavior notes
- Keys are hashed, so the real key is not visible on disk.
-
clear()iterates the directory listing (non-recursive). - Corrupt/unserializable files are treated as misses.
-
evictIfNeeded()scans the entire directory on everyset()— expensive at high file counts. Consider increasingmax_filesor batching writes.
Heads-up: The constructor references $this->directory = 'store' and the path() method prefixes it. If your Filesystem is rooted at cache/app, files land in cache/app/store/. Verify your LocalAdapter root matches your expectation.
Best for: distributed, high-throughput, multi-server deployments.
| Aspect | Detail |
|---|---|
| Client |
RedisAdapter wrapping Predis (ClientInterface). |
| Prefixing | Predis adds the configured prefix automatically. |
| Counter | Native INCRBY/DECRBY (atomic). |
| Bulk |
MGET and pipeline-based SET. |
| Expiry |
SETEX when TTL is set; SET otherwise. |
$store = new RedisStore($config, $serializer, $compressor, $metrics);Behavior notes
-
getClient()returns the raw Predis client (used byTaggedCache::gc()). -
clear()lists all keys matching*and deletes them — scoped to the Predis prefix, so it won't wipe other apps only if they use a different prefix/database. Preferflushdbon a dedicated database. - Throws
CacheExceptionif the Redis adapter class is missing or the connection fails.
| Method | Returns | Notes |
|---|---|---|
get(string $key, mixed $default = null) |
mixed |
$default on miss/expiry. |
set(string $key, mixed $value, ?int $ttl = null) |
bool |
null TTL = forever. |
delete(string $key) |
bool |
|
clear() |
bool |
Removes all entries in store. |
has(string $key) |
bool |
Non-expired presence. |
increment(string $key, int $value = 1) |
int|false |
false if non-numeric (Array/File). |
decrement(string $key, int $value = 1) |
int|false |
|
getMultiple(array $keys, mixed $default = null) |
array |
Key-preserved. |
setMultiple(array $values, ?int $ttl = null) |
bool |
|
deleteMultiple(array $keys) |
bool |
|
getStoreName() |
string |
array | file | redis. |
| Method | Description |
|---|---|
__construct(array $config = []) |
Merge config over defaults. |
store(?string $name = null): CacheStore |
Resolve (and memoize) a store. |
getTableStrategy(string $table): ?string |
Strategy for a table. |
getStrategyTtl(string $strategy): ?int |
TTL for a strategy. |
getMetrics(): CacheMetrics |
Shared metrics. |
getStampedeProtector(): StampedeProtector |
Shared protector. |
get(string $key, mixed $default = null) |
Default-store get. |
has(string $key, mixed $default = null) |
get. |
set(string $key, mixed $value, ?int $ttl = null) |
Default-store set (falls back to default_ttl). |
remember(string $key, callable $cb, ?int $ttl = null, array $tags = []) |
Get-or-compute with optional tags + stampede protection. |
delete(string $key) |
Default-store delete. |
increment(string $key, int $value = 1) |
Default-store increment. |
clear() |
Default-store clear. |
tags(array $names): TaggedCache |
Tagged wrapper. |
| Method | Description |
|---|---|
get(string $key, mixed $default = null) |
Reads using the versioned key. |
set(string $key, mixed $value, ?int $ttl = null) |
Writes using the versioned key. |
delete(string $key) |
Deletes the versioned key. |
remember(string $key, callable $cb, int $ttl) |
Get-or-compute using stampede protection if provided. |
clear() / flush()
|
Bumps every tag's version (invalidates all tagged keys). |
gc() |
No-op unless the store is a RedisStore. See note below. |
⚠️ Note onTaggedCache::remember:CacheManager::tags()constructsTaggedCachewithout passing theStampedeProtector, so the fallback simple get/set path is used. To get stampede protection with tags, construct it manually:$tc = new TaggedCache($cache->store(), ['users'], $cache->getStampedeProtector());
⚠️ Note onTaggedCache::gc(): The method scanstags:*keys but the matched hash is never compared to current tag versions — the loop body is effectively empty. As written, it does not reclaim orphans. It is safe to call but provides no benefit until completed. A production implementation should:
- For each scanned key, extract the embedded
tag:versionpairs.- Compare against the current
tag_version:<tag>values.- Delete keys whose version no longer matches.
| Concern | Recommendation |
|---|---|
| ArrayStore eviction cost |
evictLFU() is O(n). Keep max_items modest or stick with lru (O(n) due to array_splice, but small n). |
| FileStore directory scans |
set() lists the whole directory each call. Raise max_files and monitor; consider a sharded filesystem or a periodic GC cron instead. |
| FileStore write atomicity | For concurrent writers, wrap writes in a temp file + rename() in your LocalAdapter. |
Redis clear() |
KEYS * is O(n) and blocks. Use SCAN (as gc() attempts) or a dedicated DB and FLUSHDB. |
| Compression | Enable only when payloads are large (>1 KB). zlib is CPU-cheap and universal; snappy/lz4 need extensions. |
| Tagged keys growth | Since clear() orphans keys rather than deleting them, run gc() regularly (once completed) or set TTLs on all tagged entries. |
| Serialization | JSON is human-readable but lossy for PHP objects (arrays only by default). Use a PHP serializer for objects. |
| Stampede locks | The default lock is per-process. Use Redis SET NX EX for distributed safety. |
| Symptom | Likely Cause | Fix |
|---|---|---|
Cache store [x] not configured |
Store name missing in stores. |
Add it to config. |
Unsupported cache driver [x] |
driver value not array/file/redis. |
Fix the driver name or register a custom resolver. |
Redis adapter is not available |
Predis / RedisAdapter not installed. |
Install the adapter or use another store. |
Redis connection failed |
Wrong host/port/password/db. | Verify Redis is up and credentials are correct. |
Failed to write cache file |
Bad path/permissions. | Ensure the cache dir exists and is writable (0755/0775). |
| FileStore entries never found |
Filesystem root vs. directory = 'store' mismatch. |
Align the LocalAdapter root with cache/app. |
increment() returns false
|
Existing value is non-numeric. | Delete the key first or store a number. |
| Hit rate stuck at 0 | Reading through store() directly without metrics? No — stores record automatically. Check you're not resetting metrics. |
Use getMetrics()->getHitRate(). |
| Tagged keys accumulate |
clear() orphans; no GC. |
Set TTLs and/or implement gc(). |
| Stampede still occurs | Locks are in-process only. | Back locks with Redis SET NX. |
-
Always use
remember()for expensive operations. It encapsulates the check-compute-store cycle. - Set TTLs on everything unless you truly need permanent entries — it bounds memory and avoids orphan buildup.
- Prefer Redis in production for multi-server setups; use FileStore for small single-server apps; use ArrayStore in tests.
-
Tag by domain, not by route:
tags(['users', 'profiles'])beatstags(['page:user-list']). - Don't cache secrets in the FileStore unless the directory is outside the webroot and permissions are restrictive.
-
Monitor
getHitRate()— a low hit rate means your TTLs are too short or keys are too granular. -
Version your key schema (e.g.
v2:user:42) when the cached shape changes, to avoid deserializing stale structures. - Wrap cache calls defensively when using Redis in critical paths; a cache outage should degrade, not crash.
-
Use
getMultiple/setMultiplein loops to reduce round-trips (Redis pipelines them). -
Treat
clear()as a last resort — prefer tag invalidation or targeteddelete().
Replace the in-memory lock with Redis atomic SET NX EX:
protected function acquireLock(string $key): bool
{
return (bool) $this->redis->set($key, '1', ['nx', 'ex' => 5]);
}
protected function releaseLock(string $key): void
{
$this->redis->del($key);
}The circuit_breaker config is reserved. A typical implementation wraps store calls:
if ($this->breaker->isOpen($storeName)) {
return $default; // fail fast
}
try {
return $store->get($key, $default);
} catch (CacheException $e) {
$this->breaker->recordFailure($storeName);
return $default;
}A working GC should:
-
SCANkeys matchingtags:*. - Parse the hash's constituent tag versions (or store a manifest at write time).
- Compare each tag version to
tag_version:<tag>. - Delete mismatched keys.
To make step 2 feasible, store a sidecar manifest when writing:
$this->store->set("tags:manifest:{$hash}", $parts, null);Then gc() can compare $parts against current versions.
Wrap CacheStore in a PSR-16 CacheInterface implementation by delegating get/set/delete/clear/getMultiple/setMultiple/deleteMultiple/has. You'll need to translate PSR's DateInterval TTLs into integer seconds.
Q: What's the difference between has() on the manager and on the store?
On the store, has() is a true boolean presence check. On the manager, has(string $key, mixed $default = null) is currently an alias of get() — it returns the value, not a boolean. Call $cache->store()->has($key) when you need a boolean.
Q: Why does ArrayStore take a serializer and compressor it never uses?
For interface symmetry and to allow future features (e.g., simulating serialization cost in benchmarks, or switching to serialized storage to bound memory). They are currently unused in the in-memory path.
Q: Does clear() on Redis wipe other applications?
Only if they share the same database and your Predis prefix is empty. With a non-empty prefix, KEYS * from Predis is scoped to that prefix. Safest: dedicated database + FLUSHDB.
Q: Are tag versions shared across stores?
No. TaggedCache operates on whatever store you pass it. Version keys live in that store. Invalidate tags per-store.
Q: How do I cache objects, not just arrays?
JSON serialization turns objects into arrays. To preserve classes, register a SerializerInterface using serialize()/unserialize() (PHP's native format) — but be aware of security implications when unserializing untrusted data.
Q: Can I use multiple serializers at once?
Not currently. CacheManager::getSerializer() selects one based on config['serializer']. Extend the match to support more names.
Q: Why does FileStore::set() scan the directory every time?
evictIfNeeded() runs on every write to enforce max_files. For write-heavy workloads with thousands of files, this is the dominant cost. Consider a debounced eviction (e.g., only every N writes) or a background cron.
Q: Is increment() atomic on Redis?
Yes — it uses native INCRBY/DECRBY. On Array/File stores it's a read-modify-write and is not atomic across processes.
Q: How do I test the cache?
Use the array store in tests:
$cache = new CacheManager(['default' => 'array']);
$cache->set('k', 'v');
$this->assertSame('v', $cache->get('k'));Each test gets a fresh manager (or call $cache->clear() in setUp()).
// Setup
$cache = new CacheManager($config);
// Basics
$cache->set('k', $v, 300);
$cache->get('k', $default);
$cache->delete('k');
$cache->clear();
// Remember (with stampede protection)
$cache->remember('k', fn () => compute(), 300);
// Tags
$cache->tags(['users'])->set('list', $users, 600);
$cache->tags(['users'])->clear();
// Counters
$cache->increment('views', 1);
// Bulk
$cache->store()->setMultiple(['a' => 1, 'b' => 2], 300);
$cache->store()->getMultiple(['a', 'b'], null);
// Metrics
$cache->getMetrics()->getHitRate();
// Per-table TTLs
$ttl = $cache->getStrategyTtl($cache->getTableStrategy('users') ?? 'default');Machinjiri Docs
- Home
- Learn
- Build
-
Tutorials
- Routing
- Controllers and Middleware
- Views and Templates
- Database ORM
- Eloquent Database ORM
- Errors and Exceptions
- Forms and Validation
- Http
- Events and Logging
- Services and DI
- Security
- Authentication
- Caching
- File System
- Date and Time
- Debugging
- Queue and Jobs
- Task Scheduling
- Transport (Mail, SMS)
- LDAP
- Notifications
- Webhooks
- UUID Generations
- Terminal
- Helper Function
- Reference