Pramnos Cache System Guide¶
Overview¶
The Pramnos Framework includes a comprehensive caching system that supports multiple backends and provides a unified interface for all caching operations. The cache system is designed to improve application performance by storing frequently accessed data in memory or on disk.
Supported Cache Backends¶
1. Redis (Recommended)¶
- Best for: Production environments, distributed applications
- Features: Persistence, clustering, advanced data structures
- Requirements: PHP Redis extension, Redis server
2. Memcached¶
- Best for: High-performance distributed caching
- Features: Distributed memory caching, high throughput
- Requirements: PHP Memcached extension, Memcached server
3. Memcache (Legacy)¶
- Best for: Older systems requiring Memcache compatibility
- Features: Basic memory caching
- Requirements: PHP Memcache extension, Memcache server
4. File-based Cache¶
- Best for: Development, shared hosting, simple applications
- Features: No external dependencies, persistent storage
- Requirements: Writable cache directory
Configuration¶
Basic Configuration¶
// app/config/cache.php
return [
'method' => 'redis', // redis, memcached, memcache, file
'hostname' => 'localhost', // Cache server hostname
'port' => 6379, // Cache server port
'database' => 0, // Redis database index
'password' => null, // Authentication password
'prefix' => 'myapp_' // Cache key prefix
];
When nothing is configured¶
An installation with no cache section still gets a cache: the first backend whose
extension is actually installed, Redis first, then memcached, then memcache, then
file.
And a Redis cache with no connection details of its own uses the framework's Redis —
the REDIS_HOST / REDIS_PORT / REDIS_DATABASE / REDIS_PASSWORD environment
variables, or a redis settings section, resolved by
\Pramnos\Redis\ConnectionManager exactly as everything else built on it does. A
cache section that names a host keeps it, value by value: name a hostname and only the
hostname is yours.
// Nothing at all — Redis at $REDIS_HOST, or localhost, or the next backend down
'cache' => null,
// A prefix and nothing else — still finds the framework's Redis
'cache' => ['prefix' => 'myapp:'],
// A cache on its own Redis, deliberately not the framework's
'cache' => ['method' => 'redis', 'hostname' => 'cache-redis', 'port' => 6380],
Corrected 2026-08-20. The unconfigured default was the literal
'memcached', and a Redis cache assumedlocalhost. In a container stack — where Redis is a service name and memcached is not installed at all — an installation with a working Redis and nocachesection asked for memcached, failed, walked down to memcache, failed, and cached to disk. Reported from a project doing exactly that, with Redis running beside it the whole time.
Application Settings Integration¶
The cache system automatically loads configuration from application settings:
// In your application configuration
$settings = \Pramnos\Application\Settings::getInstance();
$cacheConfig = $settings->getSetting('cache');
Basic Usage¶
Creating Cache Instances¶
// Get default cache instance — uses the configured backend
$cache = \Pramnos\Cache\Cache::getInstance();
// Get cache instance with specific category and extension
$cache = \Pramnos\Cache\Cache::getInstance('user_data', 'user', 'redis');
// Get cache instance with custom settings
$cache = \Pramnos\Cache\Cache::getInstance('sessions', 'session', 'file', [
'cacheDir' => '/custom/cache/path',
'prefix' => 'session_'
]);
Omit the method unless you mean it. The third argument overrides the
application's cache.method setting for that instance. Leave it out — or pass
'' — and you get the store the application is configured to use, which is what
almost every caller wants. Name a backend only when this particular cache must
live somewhere other than the configured one (a file cache for something you
want to survive a Redis flush, for example).
Corrected 2026-08-20.
getInstance()declared$method = 'memcached', which is not a default but an answer: the constructor reads thecachesetting first and then lets a non-empty method argument overwrite it, so every caller that did not name a backend asked for memcached — the service provider,Factory::getCache(), the view cache, the SQL cache, the DevPanel cache screen. On an installation configured for Redis with no memcached to connect to, the fallback chain walked those callers down to the file adapter: the process ended up with a private on-disk cache sharing nothing with the store the rest of the application used, and the one screen that exists to show what the cache holds described that empty file store. Passing a method still wins, so nothing that named a backend changes.
Basic Operations¶
// Save data to cache
$cache->data = $userData;
$cache->timeout = 3600; // 1 hour
$success = $cache->save($userData, 'user_123');
// Load data from cache
$userData = $cache->load('user_123');
// Check if cache exists and is valid
if ($userData !== false) {
// Use cached data
echo "Welcome back, " . $userData['name'];
} else {
// Cache miss - load from database
$userData = $database->loadUser(123);
$cache->save($userData, 'user_123');
}
// Delete specific cache entry
$cache->delete('user_123');
// Clear entire category
$cache->clear('user');
Advanced Usage¶
Categories and Organization¶
Categories help organize cache entries and enable bulk operations:
// User-related cache
$userCache = \Pramnos\Cache\Cache::getInstance('users', 'user');
$userCache->save($userData, $userId);
// Session cache
$sessionCache = \Pramnos\Cache\Cache::getInstance('sessions', 'session');
$sessionCache->save($sessionData, $sessionId);
// Product cache
$productCache = \Pramnos\Cache\Cache::getInstance('products', 'product');
$productCache->save($productData, $productId);
// Clear all user cache
$userCache->clear('users');
// Clear all cache
$cache->clear('');
Corrected 2026-08-16. The three instances above were, until this date, the same object.
getInstance()held a singlestatic $instanceand returned it whatever category was asked for, so the first caller in the process decided the category for every later one — and in an application that boots providers, the first caller isCacheServiceProvider, which asks for none.Since
$this->categoryis what goes into the cache key, andsave()has no category parameter at all, the effect was that categories were accepted and discarded:View::cache()believed it wrote underviews, socache:clear --category=viewsnever matched a view fragment, and two subsystems asking for different categories shared one namespace where a key collision is possible rather than prevented.There is now one instance per
(category, extension, method). Existing entries were written under the wrong key and will miss once, which for a cache is the correct outcome rather than a migration.
Categories are a namespace, not a label. Two entries with the same id in different
categories are different entries, and clear($category) removes one category without
touching the others. If you want a value shared between subsystems, give them the same
category deliberately rather than relying on them colliding.
A category may contain underscores. user_42, schema_columns_things and
report_daily_sales are all fine, and clear() removes exactly the one you name.
Fixed 2026-08-24. They were not fine. The file adapter chose the directory for an entry by splitting its key on the first underscore — and the key is
{category}_{id}— soschema_columns_things_<id>.sqlwas written into a directory calledschema, whileclear('schema_columns_things')looked for one calledschema_columns_things, found nothing, deleted nothing and returned as though it had worked. Every category with an underscore in its name was permanently unclearable, silently, and its entries went on being served until they expired.The framework's own categories are all single words, which is why it went unnoticed for so long — but the example a few sections down in this guide recommended
$cache->category = 'user_' . $userId, which is the broken shape. The adapter is now told its category rather than recovering it from the key, andclear()also sweeps the place the old layout misfiled entries, so upgrading does not leave a pile of them behind.Entries written under the old layout are read from the old path once more only if nothing clears them first; for a cache, missing and being rewritten is the correct outcome rather than a migration.
The other adapters were checked, not assumed: Redis, Memcached and the array store all match the category against the key with a separator anchor, so none of them had this. It was the file adapter's directory derivation alone.
What clearing a category costs¶
On Redis, clear($category) costs the size of the category — it reads a set
holding that category's own keys and deletes them.
It used to cost the size of the whole database, and the reason is worth knowing because it catches people writing their own invalidation:
// Looks narrow. Is not.
$cursor = null;
do { $keys = $redis->scan($cursor, 'myprefix_views_*', 500); } while ($cursor);
MATCH filters what SCAN returns, not what it traverses. Every call walks
the entire keyspace regardless of how specific the pattern is, so the cost is a
function of everything else sharing the Redis database — other categories,
sessions, rate limiters, another application. Measured with the category held at
40 keys:
| keyspace | SCAN + MATCH |
SMEMBERS + DEL |
|---|---|---|
| 1,000 | 0.6 ms | 0.29 ms |
| 100,000 | 15.8 ms | 0.27 ms |
| 500,000 | 128.7 ms | 0.85 ms |
Model clears on every write, so this was on the path of every save.
Two consequences to know about:
- A key written into a category's namespace by something other than the
adapter is no longer removed by
clear($category). That is the cost of not searching. If you write to Redis directly and want the framework to invalidate it, write it through the adapter — or clear it yourself. - On an existing installation, the first
clear()of each category still scans, once, to catch keys written before the index existed. After that the category is never scanned again. Nothing to run; it happens on its own.
The other adapters are unchanged: Memcached cannot enumerate keys at all, and the file and array stores do not scan a keyspace, so none of them had this cost.
Cache with Timeouts¶
// Short-term cache (5 minutes)
$cache->timeout = 300;
$cache->save($temporaryData, 'temp_data');
// Long-term cache (24 hours)
$cache->timeout = 86400;
$cache->save($staticData, 'static_data');
// Permanent cache (until manually cleared)
$cache->timeout = 0;
$cache->save($permanentData, 'permanent_data');
timeout = 0 means never, on every adapter
Including the file adapter's garbage collector, which until 2026-08-27 read
timeout = 0 as "expired one second after being written" — so the sampled sweep
deleted exactly the entries a caller had asked to keep. It presented as a cache
that intermittently did not work for permanent values, which is the hardest kind
to attribute.
Sweeping the expired entries on a schedule¶
$cache->cleanup() removes what has expired and returns how many went. It is the deterministic
entry point for a housekeeping task:
The file adapter already sweeps on its own — it walks the tree, deletes what has expired and prunes
the empty directories every cache write leaves behind — but from a sampled caller: about one
call in a hundred, and never under PRAMNOS_TESTING. That is right for spreading the cost over
ordinary traffic and is not a guarantee, which is why an explicit call exists and why it goes
straight to the sweep rather than through the sampling.
Three things worth knowing:
- It is not
flushEverything(). That removes everything, valid entries included — a much more expensive thing to do to a warm cache, and not what a housekeeping schedule wants. - On Redis, Memcached and the array store it answers
0, because those drop an entry when its TTL passes: there is nothing accumulating to reclaim. A scheduled task can call this without knowing which adapter the installation configured. AdapterInterfacedoes not declare it. An application with its own adapter implements that interface, and a new method on it would break every one of them on upgrade — soCache::cleanup()asks whether the adapter has the method and answers0when it does not. If you maintain an adapter and want the sweep, extendAbstractAdapteror add a publiccleanup(): int.
The count is returned because the caller usually has somewhere to log it, and "how much was there to reclaim" is the number that says whether the schedule is frequent enough.
What a listing can and cannot see, per adapter¶
The cache dashboard asks three things — the categories, the entries, and the counts — and the answer is adapter-shaped:
| file | redis | memcached / memcache | |
|---|---|---|---|
getCategories() |
directories under the cache root | the adapter's own category index | [] — no index exists to read |
getAllItems() |
the files | the keys under its prefix | [] — the protocol cannot enumerate |
ttl |
seconds left, -1 for never |
seconds left, -1 for never |
— |
| an expired entry | listed, marked expired, until a sweep removes it | never listed: Redis evicts it | — |
Two of those were wrong until 2026-08-27, and both looked like an empty cache rather than a listing that could not see one:
- Redis reported no categories at all.
getCategories()andgetStats()read amemcachedtagsJSON blob, and nothing has ever written that key — three adapters read it, no code sets it. Meanwhile the adapter maintains a real per-category index (catindex:<category>plus acatindexed:<category>marker) thatclear()already trusts. The listing now reads that, which is the same source of truth invalidation uses. - The Redis item count was about Redis, not about the cache. It was
dbSize(), which counts the whole database: sessions, queue payloads, another application's keys, and this adapter's own bookkeeping. It now counts the entries under the cache's own prefix.
The memcached family genuinely cannot enumerate, and the dashboard says so rather than
rendering an empty table — see memcachedLimitation in the bundled cache view.
A silent fallback hides all of this
Cache falls back to files when the configured backend cannot be reached, and reports
the fallback: the DevPanel shows "file fell back from redis". That line is worth
reading. An application configured for Redis whose PHP image has no redis extension
runs on files, passes every test, and behaves differently in production — which is
exactly how the two bugs above stayed invisible.
What getAllItems() reports¶
$items = $cache->getAllItems('sessions', 50);
// [
// ['key' => 'user_7', 'ttl' => 3417, 'expired' => false, …], // seconds left
// ['key' => 'motd', 'ttl' => -1, 'expired' => false, …], // never expires
// ['key' => 'stale', 'ttl' => -84, 'expired' => true, …], // past its timeout
// ]
The key is the storage key — what the entry is stored under, which is not the
logical id it was saved with once a category or prefix is in play. The cache browser
lists these, so anything reading one back has to look it up the same way: by storage
key, through the adapter, with the namespace the entry was listed under. Reading it as
a logical id answered "not found" for every entry on the page.
ttl is the seconds actually remaining — -1 for an entry that never expires, and
absent (null) for a file the adapter cannot read as a cache entry. It used to be -1
for every live entry, which made the cache browser's TTL column read "Never" for all
of them: the one thing that column exists to say, said wrongly, on the screen an
operator opens to find out when a value will be dropped.
Conditional Caching¶
// Enable/disable caching dynamically
$cache->caching = env('CACHE_ENABLED', true);
if ($cache->caching) {
$data = $cache->load($key);
if ($data === false) {
$data = $this->generateExpensiveData();
$cache->save($data, $key);
}
} else {
$data = $this->generateExpensiveData();
}
Cache Adapters¶
Using Different Adapters¶
// Redis adapter
$redisCache = new \Pramnos\Cache\Cache('category', 'extension', 'redis', [
'hostname' => 'redis.example.com',
'port' => 6379,
'database' => 2,
'password' => 'secret'
]);
// File adapter with custom directory
$fileCache = new \Pramnos\Cache\Cache('category', 'extension', 'file', [
'cacheDir' => '/var/cache/myapp'
]);
// Memcached with persistent connection
$memcachedCache = new \Pramnos\Cache\Cache('category', 'extension', 'memcached', [
'hostname' => 'memcached.example.com',
'port' => 11211,
'persistentId' => 'myapp'
]);
Adapter-Specific Features¶
Redis Features¶
$cache = new \Pramnos\Cache\Cache('data', 'app', 'redis');
// Access Redis connection directly
$redis = $cache->getAdapter()->getConnection();
// Use Redis-specific commands
$redis->expire('key', 3600);
$redis->exists('key');
File Cache Features¶
$cache = new \Pramnos\Cache\Cache('data', 'app', 'file');
// Cleanup expired files
$cache->getAdapter()->cleanup();
// Get cache statistics
$stats = $cache->getStats();
echo "Cache entries: " . $stats['items'];
echo "Categories: " . $stats['categories'];
Performance Optimization¶
Fallback Strategy¶
The cache system automatically falls back to less optimal but available methods:
// This will try Redis first, then fall back to Memcached, then File
$cache = \Pramnos\Cache\Cache::getInstance('data', 'app', 'redis');
An unrecognised method name — a typo in a settings file — lands on the file adapter too, by the same route.
Every downgrade is logged at warning level, once per process per
transition:
This matters more than it looks. A cache that silently changes store is a bug with no symptom of its own: a value written to Redis and read back from a file store is indistinguishable from an expiry, and the application keeps answering — from a per-process cache it believes is shared. The log line is the only place that difference is visible, so treat one in production as a broken cache rather than as noise.
Two properties, deliberately:
| Property | Meaning |
|---|---|
$cache->method |
The store the instance ended up with. Follows the fallback chain, and always matches getStats()['method']. |
$cache->requestedMethod |
The store that was asked for, before any fallback. |
Read ->method when you want to know where the data actually is — a diagnostic
screen printing the requested name over the numbers of a different store is
exactly the report that hides this problem. Compare the two when you want to know
whether a fallback happened at all:
You do not have to write that comparison: Pramnos\Health\Checks\CacheBackendCheck
is registered by default and reports it as degraded on /health/check. See
Health checks.
The usual cause is the PHP extension, not the server. pramnos init now installs
redis or memcached into the generated Dockerfile when you pick that backend — it
used to write the compose service and the setting and leave the image without the
client, so a brand-new project ran on files from its first request.
Cache Key Management¶
// Use descriptive, hierarchical keys
$cache->save($userData, 'user_profile_' . $userId);
$cache->save($userSettings, 'user_settings_' . $userId);
$cache->save($userPermissions, 'user_permissions_' . $userId);
// Group related data. An underscore in the category is fine — everything under
// `user_42` is removed by `clear('user_42')` and nothing else is touched.
$cache->category = 'user_' . $userId;
$cache->save($profileData, 'profile');
$cache->save($settingsData, 'settings');
$cache->save($permissionsData, 'permissions');
// ...and this is how you drop all of it when that user changes:
$cache->clear('user_' . $userId);
Before 2026-08-24 the
clear()on the last line removed nothing, because the category contains an underscore — see the note under Categories and Organization.
Batch Operations¶
// Cache multiple related items
$users = $this->database->getUsers();
foreach ($users as $user) {
$cache->save($user, 'user_' . $user['id']);
}
// Clear related caches
$cache->clear('user_' . $userId); // Clear all user-related cache
Flat-Key Caching (FlatCache)¶
Cache / SimpleCache are category-based: the key you pass is mangled by
_generateCacheName() (sanitised, with the prefix, category and extension folded
into the physical key), and PSR-16's SimpleCache additionally rejects keys
containing the reserved characters {}()/\@:.
When an application addresses the cache with its own flat, explicit keys —
especially colon-namespaced ones like chat:messages:hash or radio:now_playing
— use Pramnos\Cache\FlatCache instead. It is a PSR-16 cache that stores and
reads the key verbatim under a fixed prefix, and is backend-agnostic: it
works over any cache adapter, exactly like the category cache.
use Pramnos\Cache\FlatCache;
use Pramnos\Cache\Adapter\RedisAdapter;
use Pramnos\Cache\Adapter\ArrayAdapter;
// Production: Redis-backed, keyed under "app:", colon keys kept verbatim.
$cache = new FlatCache(new RedisAdapter('127.0.0.1', 6379, 0, null, 'app:'), 'app:');
$cache->set('chat:messages:hash', $hash, 300); // stored at app:chat:messages:hash
$hash = $cache->get('chat:messages:hash'); // arrays/objects round-trip
$cache->has('radio:now_playing');
$cache->delete('chat:messages:hash');
// Tests: swap in the in-memory adapter — same class, no live server.
$cache = new FlatCache(new ArrayAdapter('app:'), 'app:');
Choosing between them:
| Need | Use |
|---|---|
| Cache a computed value under a logical id, grouped in categories | Cache / SimpleCache |
| Full control of the exact (possibly colon-namespaced) key | FlatCache |
FlatCache implements Psr\SimpleCache\CacheInterface, so it drops into any
PSR-16-aware library. Serialisation and TTL are delegated to the adapter. A
stored boolean false is reported as a miss (adapters signal "not found" with
false); wrap it in an array if you must distinguish it.
Atomic counters (increment / decrement / counter)¶
Rate limits, failed-login trackers, spam-violation tallies and monotonic epoch
markers are counters, not cached values: they need atomic updates under
concurrency and a bare-integer representation. FlatCache exposes a dedicated
counter capability for them, separate from the value round-trip above:
// Sliding-window rate limiter: +1 and (re)set a 900s TTL in one atomic step.
$attempts = $cache->increment("login_attempts:{$ip}", 1, 900); // returns the new total
if ($attempts > 5) { /* locked out */ }
$cache->counter("login_attempts:{$ip}"); // read current value (0 if absent, key NOT created)
$cache->decrement('slots_free'); // negative deltas via decrement()
$cache->delete("login_attempts:{$ip}"); // reset on success
Semantics:
increment(string $key, int $by = 1, null|int|\DateInterval $ttl = null): intadds$byand returns the new total; when$ttlis given the expiry is reset on every call (a sliding window).decrement(...)is the mirror image.counter(string $key): intreads the current value —0when absent, and it does not create the key (unlikeincrement($key, 0)would).
A counter key is stored as a bare integer, distinct from the serialised
{data,time} envelope that set()/get() use — so never mix the two on the
same key (read a counter with counter(), never get()).
Backend support is layered so it is fully backwards compatible:
AdapterInterfaceis unchanged — existing third-party adapters keep working.AbstractAdapterprovides a concrete, non-atomic default (counter()+save()), so every adapter that extends it gains the capability for free.RedisAdapteroverrides it with nativeINCRBY/DECRBY+EXPIRE, making it genuinely atomic across processes.- If you inject a bare
AdapterInterfacewithout these methods,FlatCachetransparently falls back to a get+set emulation.
The same capability on the classic Cache object¶
FlatCache is the PSR-16-shaped front end. Code that holds a classic
\Pramnos\Cache\Cache — the middleware pipeline, for one — reaches the same
counters through two additions:
if ($cache->supportsAtomicCounter()) {
$count = $cache->increment($key, $ttl); // int, or false on failure
}
Two differences from FlatCache::increment() are deliberate:
- The expiry is fixed, not sliding. It is applied by whichever call creates the key and is not refreshed afterwards. A sliding expiry never lets a busy key die, so a rate-limit counter under sustained traffic would climb for ever and lock the client out permanently.
- Failure is
false, not a silent fallback.falsemeans "the counter did not work" — a dropped Redis connection, say — and is not zero. A caller doing security work must be able to tell the difference; reading a failure as an empty bucket opens the door at the moment the site is under strain.
supportsAtomicCounter() answers whether the backing adapter can do this at
all. Redis (INCRBY) and Memcached (increment, with creation through the
atomic add) can; Array and File cannot, and say so rather than pretending.
Do not probe with method_exists()
Every adapter has an increment() method — AbstractAdapter provides a
working non-atomic default, so method_exists($adapter, 'increment') is
true for the File adapter too. Asking that question is how the first version
of this reported the File adapter as atomic and sent the rate limiter down
the "exact under concurrency" path on a backend that loses increments.
Ask supportsAtomicCounter(), which is false in AbstractAdapter and
overridden to true only by the adapters that mean it.
A note on expiry, since the two entry points differ deliberately:
FlatCache::increment() keeps its documented sliding TTL, refreshed on
every call. The adapter's own default — and therefore Cache::increment() — is
the fixed window, because a sliding expiry on a rate-limit counter never
lets a busy key die: sustained traffic refreshes it on every hit, the count
climbs for ever, and the client is locked out permanently.
Atomic swap (change detection / de-duplication)¶
swap() sets a key to a new value and returns the previous one in a single
atomic step — the classic "record only when it changed" primitive:
// Record a play only when the now-playing track differs from the last one.
$previous = $cache->swap('radio:last_track', $display); // returns old value, sets new
if ($previous === $display) {
return; // unchanged — skip
}
Like the counters, swap() is a raw-key operation: the value is stored
verbatim (not through the {data,time} envelope), so read it back with another
swap(), never get(). Backend support is layered identically —
AbstractAdapter provides a non-atomic read-then-write default, RedisAdapter
overrides it with native GETSET (genuinely atomic across processes), and
AdapterInterface is unchanged (fully backwards compatible).
Structured operations (hash / list / enumeration)¶
Beyond opaque values, the flat cache exposes Redis-style structured operations — for data that is a cache but needs a shape (a bounded recent-items list, a field-addressed map) rather than one blob:
// Hash (field-addressed map) — values may be any serialisable type.
$cache->hashSet('msg:hash', $id, ['user' => 'a', 'text' => 'hi'], ttl: 86400);
$cache->hashGet('msg:hash', $id); // ['user' => 'a', 'text' => 'hi']
$cache->hashDelete('msg:hash', $id);
$cache->hashGetAll('msg:hash'); // [id => [...], ...]
// List (Redis LPUSH/LTRIM/LRANGE semantics) — a bounded recent-items cache.
$cache->listPush('recent', $item); // prepend; returns new length
$cache->listTrim('recent', 0, 99); // keep newest 100
$cache->listRange('recent', 0, -1); // newest-first, decoded
$cache->expire('recent', 86400); // (re)set TTL
$cache->keys('banned:*'); // enumerate (logical keys)
Enumeration is a cursor walk, and clearing is scoped to your prefix¶
keys() and the sweep behind clear() use Redis SCAN, not KEYS. The difference is not
efficiency: KEYS holds the whole server for the length of the sweep, and the sweep is over a
production cache. So both walk the keyspace in bounded steps and reassemble the batches, and both are
safe to call on a database with a million keys in it.
clear('') deletes <prefix>* rather than issuing FLUSHDB — unless no prefix is configured, in
which case it does flush the database and logs a warning saying so. Several installations routinely
share one Redis, so set a prefix: without one, one application's «clear the cache» empties another
application's sessions, and reports success.
supportsKeyEnumeration() is how a caller finds out whether any of this is available before relying on
it. AbstractAdapter answers false — the File and Array adapters have no keyspace to walk — and
RedisAdapter answers true.
Semantics:
- Field/element values are serialised, so arrays/objects round-trip (unlike the raw counters/swap). Read them back with the same structured methods.
listPush/listTrim/listRangefollow Redis LPUSH/LTRIM/LRANGE (newest-first, inclusive ranges, negative indices).keys($pattern)returns matching keys in the logical key-space (the cache prefix is stripped) and needs an enumeration-capable adapter;RedisAdapteruses a non-blockingSCAN, other adapters return an empty list.
Backend support is layered exactly like the counters: AbstractAdapter keeps the
whole structure under one key via load/save (non-atomic default), RedisAdapter
overrides with native HSET/LPUSH/SCAN, and AdapterInterface is unchanged.
Two TTLs, and which one decides¶
save($key, $data, $timeout) records the TTL with the entry. load($key, $timeout)
takes a timeout too, and it means something different: the maximum age this
particular reader will accept, which is what Cache::load($id, $category, $timeout)
has always passed down.
The entry's own TTL decides whether it is still valid. The reader's is an additional limit on top:
$cache->save('report', $rows, 86400); // good for a day
$adapter->load('report', 0); // whatever is stored, if it has not expired
$adapter->load('report', 60); // …only if it is under a minute old
Both directions of that used to be wrong on the File adapter, which decided expiry from the reader's argument alone and ignored the TTL it had stored:
- a structure saved with no expiry vanished an hour after it was written, because
hashGet()andlistRange()read withload()'s 3600 default and meant nothing by it; - a counter written with a one-second TTL never expired, because
counter()reads with0and the check was$timeout > 0— so a rate-limit window never closed.
Structured and counter reads pass 0 now: they want what is stored, and have no
opinion about its age.
Keys become paths on the File adapter¶
generateKey() cleans the prefix, the category and the extension; the id is passed
through as given, on purpose — on a server-backed adapter any byte is a legal key.
FileAdapter sanitises the whole key where it builds the path, because there a / is
a directory separator and .. is a parent:
Without that, a/b wrote outside its own category directory — where
clear('a') would never find it again — and ../../x wrote outside the cache
directory altogether. One consequence worth knowing about: a key whose shape changes
under sanitisation misses once against entries written by an earlier version, and is
then rewritten.
Integration Examples¶
Model-Level Caching¶
class UserModel extends \Pramnos\Application\Model
{
private $cache;
public function __construct($controller, $name = '')
{
parent::__construct($controller, $name);
$this->cache = \Pramnos\Cache\Cache::getInstance('users', 'user');
}
public function load($userId)
{
// Try cache first
$cacheKey = 'user_' . $userId;
$userData = $this->cache->load($cacheKey);
if ($userData === false) {
// Cache miss - load from database
$sql = $this->application->database->prepareQuery(
"SELECT * FROM users WHERE id = %d", $userId
);
$result = $this->application->database->query($sql);
if ($result->numRows > 0) {
$userData = $result->fields;
// Cache for 1 hour
$this->cache->timeout = 3600;
$this->cache->save($userData, $cacheKey);
}
}
return $userData;
}
public function update($userId, $data)
{
// Update database
$this->updateDatabase($userId, $data);
// Invalidate cache
$this->cache->delete('user_' . $userId);
}
}
View-Level Caching¶
class ProductView extends \Pramnos\Application\View
{
public function display($template = 'default')
{
$cache = \Pramnos\Cache\Cache::getInstance('views', 'product');
$cacheKey = 'product_list_' . $this->page . '_' . $this->category;
$html = $cache->load($cacheKey);
if ($html === false) {
// Generate HTML
$html = $this->renderTemplate($template);
// Cache for 30 minutes
$cache->timeout = 1800;
$cache->save($html, $cacheKey);
}
return $html;
}
}
API Response Caching¶
class ProductController extends \Pramnos\Application\Controller
{
public function getProducts()
{
$cache = \Pramnos\Cache\Cache::getInstance('api', 'products');
$cacheKey = 'products_' . md5(serialize($_GET));
$response = $cache->load($cacheKey);
if ($response === false) {
$products = $this->getModel('Product')->getList($_GET);
$response = [
'products' => $products,
'total' => count($products),
'timestamp' => time()
];
// Cache API response for 15 minutes
$cache->timeout = 900;
$cache->save($response, $cacheKey);
}
return $this->response($response);
}
}
Debugging and Monitoring¶
Cache Statistics¶
$cache = \Pramnos\Cache\Cache::getInstance();
// Get cache statistics
$stats = $cache->getStats();
print_r($stats);
/* Output:
Array(
[method] => redis
[categories] => 15
[items] => 1247
)
*/
Testing Cache Connection¶
$cache = \Pramnos\Cache\Cache::getInstance();
// Test cache connectivity
if ($cache->testConnection()) {
echo "Cache is working properly";
} else {
echo "Cache connection failed";
}
Debugging Cache Issues¶
// Enable cache debugging
$cache = \Pramnos\Cache\Cache::getInstance('debug', 'test');
// Test save/load cycle
$testData = ['test' => 'data', 'timestamp' => time()];
$cache->save($testData, 'test_key');
$loadedData = $cache->load('test_key');
if ($loadedData === $testData) {
echo "Cache working correctly";
} else {
echo "Cache issue detected";
}
// Check adapter details
$adapter = $cache->getAdapter();
echo "Using adapter: " . get_class($adapter);
Best Practices¶
1. Use Appropriate Cache Keys¶
// Good: Descriptive and hierarchical
$cache->save($data, 'user_profile_' . $userId);
$cache->save($data, 'product_details_' . $productId);
$cache->save($data, 'api_search_' . md5($searchQuery));
// Bad: Generic or collision-prone
$cache->save($data, 'data');
$cache->save($data, $id);
2. Set Appropriate Timeouts¶
// Frequently changing data - short timeout
$cache->timeout = 300; // 5 minutes
$cache->save($liveData, $key);
// Relatively stable data - medium timeout
$cache->timeout = 3600; // 1 hour
$cache->save($userData, $key);
// Static data - long timeout
$cache->timeout = 86400; // 24 hours
$cache->save($configData, $key);
3. Handle Cache Failures Gracefully¶
try {
$data = $cache->load($key);
if ($data === false) {
$data = $this->loadFromDatabase($key);
$cache->save($data, $key);
}
} catch (\Exception $e) {
// Cache failed - continue without caching
\Pramnos\Logs\Logger::log('Cache error: ' . $e->getMessage());
$data = $this->loadFromDatabase($key);
}
4. Use Categories for Organization¶
// Organize by feature
$userCache = \Pramnos\Cache\Cache::getInstance('users', 'user');
$productCache = \Pramnos\Cache\Cache::getInstance('products', 'product');
$sessionCache = \Pramnos\Cache\Cache::getInstance('sessions', 'session');
// Clear by category when needed
$userCache->clear('users'); // Clear only user-related cache
5. Cache Invalidation Strategy¶
class UserController extends \Pramnos\Application\Controller
{
private function invalidateUserCache($userId)
{
$cache = \Pramnos\Cache\Cache::getInstance('users', 'user');
// Clear specific user cache
$cache->delete('user_profile_' . $userId);
$cache->delete('user_settings_' . $userId);
$cache->delete('user_permissions_' . $userId);
// Clear related caches
$cache->clear('user_' . $userId);
}
public function updateUser($userId, $data)
{
// Update database
$this->updateUserInDatabase($userId, $data);
// Invalidate cache
$this->invalidateUserCache($userId);
}
}
A flush must never break the operation that triggered it¶
FileAdapter::listDirectoryFiles() guarded its walk with is_dir(), which is a check followed
by a use — and the directory can go between the two. Another request flushing the same group, or
this adapter's own cleanEmptyDirectories() from a concurrent call, and the iterator raises:
UnexpectedValueException: RecursiveDirectoryIterator::__construct(…/var/cache/userlist):
Failed to open directory: No such file or directory
FileAdapter.php → Cache.php → Database.php → User.php ← User::activate()
The last line is the whole lesson. The throw did not break a cache flush; it broke a user
activation. save() flushes the user list, the flush raised, and the operation somebody asked
for failed because of housekeeping that had already succeeded — the directory was gone, which
is the state the flush wanted.
Caught now, around the loop rather than the constructor alone, since a subdirectory can vanish mid-walk with the same result. Whatever was collected before it went is returned, because those files are the ones still there to delete. Six call sites in the adapter go through that one walk.
The is_dir() guard stays: it is the common case, and paying for an exception on every flush of
a group nothing ever wrote to would be a cost for a condition that is normal.
If you write an adapter, take the same view. A cache is an optimisation, and the caller is
almost always in the middle of something that matters more. directoryIterator() is a protected
seam precisely so this can be tested — a race cannot be reproduced by arranging files, so the
only honest way to cover the catch is to make the walk fail on purpose.
What each adapter answers when its server is gone¶
Every adapter method wraps its call in the same guard: log the exception, return an empty value. The type of that empty value is the contract, not the fact that something came back — the wrong kind of nothing becomes a second failure in the caller, a step away from the one that happened.
| Method | Answer when the server is unreachable | What the wrong answer would do |
|---|---|---|
load(), save(), delete() |
false |
— |
counter() |
0 |
false in arithmetic: false + 1 is 1, for ever |
increment(), decrement() |
false |
0 reads as "nothing yet" and lets a limiter pass everything |
swap() |
null |
— |
hashGet() |
the caller's $default |
null overrides what the caller said a miss means |
hashGetAll(), listRange(), keys() |
[] |
false in a foreach |
listPush() |
0 |
— |
counter() and increment() differing is deliberate. counter() is a read — "how many so far" —
and zero is the truthful answer for a counter nobody can see. increment() is a write, and
answering 0 would claim the increment happened.
Note also that these arms only run when the cache fails mid-request. An adapter that never
connected answers from its own connected flag without a round trip, so the guards are about a
server that went away while the application was talking to it.
clear() with no prefix empties the whole server¶
Memcached cannot enumerate its keys. So a clear() with no category has two very different
behaviours, and which one you get depends on whether a prefix is configured:
- With a prefix, the adapter clears the category indexes it maintains itself. Other tenants' keys are untouched.
- Without one, it calls
flush(), which empties the entire server — every co-tenant's data included. It writes a line to the log saying so first, and that is the only warning there is.
Which makes the prefix an isolation boundary rather than a naming convenience. Set one on any server that more than one installation talks to. Redis had the same break and was fixed the same way.
The Memcached counter, and why it calls the server up to three times¶
increment() is a rate limiter's correctness, so the sequence is worth knowing:
increment— succeeds if the counter exists, and that is the whole call.- If it fails, the key is absent:
addcreates it, atomically, with the expiry. Only the call that creates the counter sets the expiry — a fixed window, not a sliding one, so a client that keeps trying is not locked out for ever. - If
addfails too, another request created it in between.incrementagain, on top of theirs.
No increment is lost in a race, which is the entire reason for using the server's counter instead of a read-modify-write. The path that matters is the third one: returning the amount added there, rather than incrementing, would discard the winner's count and let a limiter undercount by one request per race.
Troubleshooting¶
Common Issues¶
- Cache Not Working
- Check if the cache backend is running
- Verify connection credentials
-
Ensure proper file permissions for file cache
-
Performance Issues
- Monitor cache hit rates
- Optimize cache key strategies
-
Consider cache distribution across servers
-
Memory Issues
- Set appropriate timeouts
- Implement cache size limits
- Regular cache cleanup
Error Handling¶
$cache = \Pramnos\Cache\Cache::getInstance();
// Graceful degradation
if (!$cache->caching) {
// Cache is disabled - work without cache
$data = $this->loadFromSource();
} else {
try {
$data = $cache->load($key);
if ($data === false) {
$data = $this->loadFromSource();
$cache->save($data, $key);
}
} catch (\Exception $e) {
// Log error and continue
\Pramnos\Logs\Logger::log('Cache error: ' . $e->getMessage());
$data = $this->loadFromSource();
}
}
Advanced Cache Strategies¶
Cache Invalidation Patterns¶
The Pramnos Cache system provides sophisticated invalidation strategies to ensure data consistency:
Tag-based Cache Invalidation¶
// Cache with category tags for bulk invalidation
$userCache = \Pramnos\Cache\Cache::getInstance('users', 'user');
$productCache = \Pramnos\Cache\Cache::getInstance('products', 'product');
// Save related data
$userCache->save($userData, 'user_' . $userId);
$userCache->save($userProfile, 'profile_' . $userId);
$userCache->save($userSettings, 'settings_' . $userId);
// Invalidate all user-related cache at once
$userCache->clear('users'); // Clears all cache entries in 'users' category
Hierarchical Cache Keys¶
// Organize cache keys hierarchically for precise invalidation
class OrderCache
{
private $cache;
public function __construct()
{
$this->cache = \Pramnos\Cache\Cache::getInstance('orders', 'order');
}
public function cacheOrderData($userId, $orderId, $data)
{
// Cache at multiple levels for different access patterns
$this->cache->save($data, "user_{$userId}_order_{$orderId}");
$this->cache->save($data, "order_details_{$orderId}");
// Cache order list for user
$userOrders = $this->getUserOrders($userId);
$userOrders[] = $data;
$this->cache->save($userOrders, "user_{$userId}_orders_list");
}
public function invalidateUserOrders($userId)
{
// Clear specific user's order cache
$this->cache->delete("user_{$userId}_orders_list");
// Could also clear all user-specific order entries
// This would require maintaining a list of order IDs per user
}
}
Advanced Backend Features¶
Redis-Specific Features¶
$redisCache = \Pramnos\Cache\Cache::getInstance('advanced', 'redis', 'redis');
// Access Redis connection directly for advanced operations
if ($redisCache->getAdapter() instanceof \Pramnos\Cache\Adapter\RedisAdapter) {
$redis = $redisCache->getAdapter()->getConnection();
// Use Redis sets for complex data relationships
$redis->sadd('user_sessions:' . $userId, $sessionId);
$redis->expire('user_sessions:' . $userId, 3600);
// Use Redis lists for queues
$redis->lpush('notification_queue', json_encode($notificationData));
// Use Redis sorted sets for leaderboards
$redis->zadd('user_scores', $score, $userId);
}
Memcached Connection Pooling¶
// Use persistent connections for better performance
$memcachedCache = new \Pramnos\Cache\Cache('sessions', 'session', 'memcached', [
'hostname' => 'memcached.example.com',
'port' => 11211,
'persistentId' => 'app_persistent_pool'
]);
Performance Monitoring and Statistics¶
Cache Performance Metrics¶
class CacheMonitor
{
public function getCacheStatistics()
{
$caches = [
'users' => \Pramnos\Cache\Cache::getInstance('users', 'user'),
'products' => \Pramnos\Cache\Cache::getInstance('products', 'product'),
'sessions' => \Pramnos\Cache\Cache::getInstance('sessions', 'session')
];
$stats = [];
foreach ($caches as $name => $cache) {
$stats[$name] = $cache->getStats();
}
return $stats;
}
public function monitorCacheHealth()
{
$cache = \Pramnos\Cache\Cache::getInstance('health_check', 'monitor');
$startTime = microtime(true);
$testSuccess = $cache->testConnection();
$responseTime = (microtime(true) - $startTime) * 1000; // ms
return [
'status' => $testSuccess ? 'healthy' : 'failed',
'response_time_ms' => round($responseTime, 2),
'timestamp' => time()
];
}
}
Cache Warming Strategies¶
Preemptive Cache Population¶
class CacheWarmup
{
public function warmupUserCache($userId)
{
$cache = \Pramnos\Cache\Cache::getInstance('users', 'user');
// Load and cache frequently accessed user data
$userData = $this->loadUserFromDatabase($userId);
$cache->timeout = 3600; // 1 hour
$cache->save($userData, 'user_' . $userId);
// Warm up related data
$userSettings = $this->loadUserSettingsFromDatabase($userId);
$cache->save($userSettings, 'settings_' . $userId);
$userPermissions = $this->loadUserPermissionsFromDatabase($userId);
$cache->timeout = 1800; // 30 minutes for permissions
$cache->save($userPermissions, 'permissions_' . $userId);
}
public function warmupPopularProducts()
{
$cache = \Pramnos\Cache\Cache::getInstance('products', 'product');
$popularProducts = $this->getPopularProductIds();
foreach ($popularProducts as $productId) {
$productData = $this->loadProductFromDatabase($productId);
$cache->timeout = 7200; // 2 hours for popular products
$cache->save($productData, 'product_' . $productId);
}
}
}
Multi-Layer Caching¶
Implementing Cache Layers¶
class LayeredCache
{
private $l1Cache; // Fast, small cache (Redis)
private $l2Cache; // Larger, slower cache (File)
public function __construct()
{
$this->l1Cache = \Pramnos\Cache\Cache::getInstance('l1', 'memory', 'redis');
$this->l2Cache = \Pramnos\Cache\Cache::getInstance('l2', 'disk', 'file');
}
public function get($key)
{
// Try L1 cache first
$data = $this->l1Cache->load($key);
if ($data !== false) {
return $data;
}
// Fall back to L2 cache
$data = $this->l2Cache->load($key);
if ($data !== false) {
// Promote to L1 cache
$this->l1Cache->timeout = 300; // 5 minutes in L1
$this->l1Cache->save($data, $key);
return $data;
}
return false;
}
public function set($key, $data, $timeout = 3600)
{
// Save to both layers
$this->l1Cache->timeout = min(300, $timeout); // Max 5 min in L1
$this->l1Cache->save($data, $key);
$this->l2Cache->timeout = $timeout;
$this->l2Cache->save($data, $key);
}
}
Error Recovery and Fallback¶
Graceful Degradation Patterns¶
class RobustCache
{
private $primaryCache;
private $fallbackCache;
private $logger;
public function __construct()
{
$this->primaryCache = \Pramnos\Cache\Cache::getInstance('primary', 'app', 'redis');
$this->fallbackCache = \Pramnos\Cache\Cache::getInstance('fallback', 'app', 'file');
$this->logger = \Pramnos\Logs\Logger::getInstance();
}
public function getWithFallback($key, $dataLoader = null)
{
try {
$data = $this->primaryCache->load($key);
if ($data !== false) {
return $data;
}
} catch (\Exception $e) {
$this->logger->logError('Primary cache failed: ' . $e->getMessage());
}
try {
$data = $this->fallbackCache->load($key);
if ($data !== false) {
return $data;
}
} catch (\Exception $e) {
$this->logger->logError('Fallback cache failed: ' . $e->getMessage());
}
// No cache available, load fresh data
if ($dataLoader && is_callable($dataLoader)) {
$data = $dataLoader();
$this->setWithFallback($key, $data);
return $data;
}
return false;
}
private function setWithFallback($key, $data, $timeout = 3600)
{
try {
$this->primaryCache->timeout = $timeout;
$this->primaryCache->save($data, $key);
} catch (\Exception $e) {
$this->logger->logError('Primary cache save failed: ' . $e->getMessage());
}
try {
$this->fallbackCache->timeout = $timeout;
$this->fallbackCache->save($data, $key);
} catch (\Exception $e) {
$this->logger->logError('Fallback cache save failed: ' . $e->getMessage());
}
}
}
Development and Debugging Tools¶
Cache Inspector¶
class CacheInspector
{
public function dumpCacheContents($category = '')
{
$cache = \Pramnos\Cache\Cache::getInstance($category, 'debug');
$stats = $cache->getStats();
echo "<h3>Cache Statistics</h3>\n";
echo "<pre>" . print_r($stats, true) . "</pre>\n";
$categories = $cache->getAdapter()->getCategories();
echo "<h3>Available Categories</h3>\n";
echo "<pre>" . print_r($categories, true) . "</pre>\n";
}
public function validateCacheIntegrity()
{
$cache = \Pramnos\Cache\Cache::getInstance('integrity_test', 'test');
$testCases = [
'string_data' => 'Hello World',
'array_data' => ['key1' => 'value1', 'key2' => 'value2'],
'object_data' => (object)['property' => 'value'],
'numeric_data' => 12345,
'boolean_data' => true
];
$results = [];
foreach ($testCases as $key => $testData) {
$cache->save($testData, $key);
$retrieved = $cache->load($key);
$results[$key] = [
'original' => $testData,
'retrieved' => $retrieved,
'match' => $testData === $retrieved
];
$cache->delete($key);
}
return $results;
}
}
Production Optimization¶
High-Performance Configuration¶
Redis Production Setup¶
// Production Redis configuration
$productionCache = new \Pramnos\Cache\Cache('production', 'app', 'redis', [
'hostname' => 'redis-cluster.example.com',
'port' => 6379,
'database' => 0,
'password' => 'secure_redis_password',
'prefix' => 'prod_app_'
]);
// Use appropriate timeouts for different data types
$productionCache->timeout = 86400; // 24 hours for static data
$productionCache->save($configData, 'app_config');
$productionCache->timeout = 300; // 5 minutes for dynamic data
$productionCache->save($userSession, 'session_' . $sessionId);
Memory Management¶
class CacheMemoryManager
{
public function cleanupExpiredEntries()
{
$fileCache = \Pramnos\Cache\Cache::getInstance('cleanup', 'app', 'file');
if ($fileCache->getAdapter() instanceof \Pramnos\Cache\Adapter\FileAdapter) {
// File adapter has built-in cleanup method
$fileCache->getAdapter()->cleanup();
}
}
public function monitorMemoryUsage()
{
$cache = \Pramnos\Cache\Cache::getInstance('memory', 'monitor');
$stats = $cache->getStats();
$memoryUsage = [
'cache_items' => $stats['items'],
'cache_categories' => $stats['categories'],
'php_memory_usage' => memory_get_usage(true),
'php_memory_peak' => memory_get_peak_usage(true)
];
return $memoryUsage;
}
}
The Pramnos Cache system provides a robust, flexible foundation for application performance optimization while maintaining simplicity and reliability across different deployment environments. With these advanced patterns and strategies, you can build highly scalable and performant caching solutions that gracefully handle failures and provide optimal user experiences.
Related Documentation¶
- Framework Guide - Core framework patterns and MVC architecture
- Database API Guide - Database operations and query optimization
- Authentication Guide - Caching user sessions and permissions
- Console Commands Guide - CLI tools for cache management
- Logging System Guide - Cache performance monitoring and debugging
- Media System Guide - Caching processed images and media files
- Internationalization Guide - Caching translated content and language data
For implementation examples and integration patterns, see the Framework Guide for guidance on using caching in controllers and models.
Default flat cache from the ConnectionManager¶
FlatCache::default() returns a lazy, process-wide flat cache backed by a
RedisAdapter bound to the shared Pramnos\Redis\ConnectionManager
(host/port/database/password + per-install prefix). Configure the manager once in
bootstrap (ConnectionManager::setInstance(...)) and read the cache anywhere,
without wiring the adapter yourself:
FlatCache::setDefault(?FlatCache) overrides it (bootstrap wiring) or resets it
to rebuild (setDefault(null) — the test seam). Colon-namespaced keys are stored
verbatim under the install prefix, and the atomic counter + structured (hash/list/
expire/keys) operations are available on the returned instance.