Skip to content

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

  • 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

The cache reads the cache setting — the 'cache' => [...] block that init writes into app/config/settings.php:

// app/config/settings.php
'cache' => [
    '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
],

Only keys that name a public Cache property are applied (method, hostname, port, database, password, prefix, category, extension, timeout, caching); anything else in the block is ignored. The file adapter's directory is the CACHE_PATH constant, or sys_get_temp_dir() . '/pramnos_cache' when it is not defined.

Turning it off

Two ways, and both mean it.

// No cache on this installation
'cache' => ['method' => false],       // or 'none', 'off', 'false', 'disabled'

// Same thing, said the other way
'cache' => ['caching' => false],

method names the store and caching is the switch — either one reaching a value that means off leaves method as 'none', no adapter built, and no connection attempted. save() and load() answer false, which is the path every caller already takes on a miss, so nothing else has to change.

A few notes on the edges:

  • An empty string is nothing; null and false are off. A method of '' in the settings — or a ''/null method argument to getInstance() / new Cache() — selects the configured or default backend. In the cache settings, 'method' => null turns caching off just as false does.
  • '0' does not count. It is what an unchecked checkbox posts, and a settings form saving '0' into a method field must not silently take an installation's cache away. The words are an enumerated list, not a falsiness test.
  • caching is checked before the adapter is built, not after. A cache that is off opens no socket, which is the reason to reach for the switch during an incident.

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],

This matters in a container stack, where Redis is a service name rather than localhost and memcached is often not installed at all: an installation with a working Redis and no cache section finds it.

When a cache that was working stops

A configured backend that cannot be reached — a redis restart, a network blip — falls to the local file cache, rather than to the next network technology down. Two things follow from that, both deliberate:

A cache miss is never fatal. Every rung of the ladder constructs a class, and anything thrown while doing so would otherwise escape the cache, its caller and the request — in a queue worker, killing it mid-task and leaving the row processing behind a lock nobody holds. A caching layer whose fault domain is larger than the thing it accelerates is not a caching layer, so any failure lands on the file adapter and writes a log line. If the filesystem fails too, caching switches off — load() and save() return false on that, so callers take the path they take when nothing is cached.

The floor is the local disk rather than an in-memory store, because an array cache is per instance and Database::cacheflush() would clear one object's memory while the data sat in another's; and rather than off, because features are built on the cache — one of them is single-use enforcement for a TOTP code, and removing a security control quietly is not a degradation.

Redis does not silently become memcached. They hold different data. A worker that moves to a different, empty cache mid-run is reading a different world from the web requests beside it, and the only thing that says so is a log line nobody is watching at the time. A cache that is absent is a state every caller already handles; a cache that is someone else's is not.

The ladder is still available for an installation that has weighed the trade. The switch is read from app/app.php only (applicationInfo['cache']['fallback']) — a fallback key in the settings-file cache block is ignored:

// app/app.php
'cache' => ['fallback' => true],   // redis → memcached → memcache → file

Choosing a backend whose extension is missing is a configuration error rather than a runtime failure, and it takes the same path: a log line and the local file cache.

And a credential the cache or a tool needs is refused rather than stored in the clear — see KEY_REQUIRED_SETTINGS for database_readonly_dsn, which needs an APP_KEY before it can be set at all.

What redis keeps, and why nothing scans

The prefix is used as a key's name holds it: without _, which separates a name's parts, and without anything outside letters, digits, . and -. So myapp: is myapp, and the SQL cache's per-connection part, postgresql…:5432…, loses its colon. Saving, reading and clearing all start from that one form.

Three keys per installation prefix, and none of them is found by looking:

key what it is bounded by
<prefix>_<category>_<id>.… a cache entry its own TTL
<prefix>catindex:<category> a set of that category's keys its members' TTLs — each save pushes the set's expiry an hour past the newest
<prefix>catnames a set of the category names its own expiry, pushed an hour past its newest member — plus removal when a category is cleared

So invalidating a category is SMEMBERS + DEL, and listing the categories is one SMEMBERS. Neither asks redis to find anything.

Why that is worth spelling out: the obvious way to answer "which keys are in this category" is a pattern — and SCAN … MATCH filters what it returns, not what it traverses, so it costs the size of the whole database however narrow the pattern looks. KEYS is worse: same cost, and it blocks the server for the duration, so a cache dashboard on a large instance stalls everything sharing it.

Invalidation runs on every Model::save() and enumeration on every dashboard load and every getStats(), so both have to be index reads: the per-category set serves the first, catnames the second.

And a central index has to be pruned or it becomes the problem. An index key with no TTL grows without bound and makes slower the very sweeps it exists to avoid.

catnames holds names rather than entities, and it goes away three ways: a cleared category is removed by the clear, a category whose last entry expired is removed the next time the list is read, and the set itself expires an hour past its newest member's own TTL.

The expiry is what bounds it. clearCategory() removes a name, so a category that is cleared leaves nothing; but the ordinary life of a cached read is to expire on its own TTL, and nothing clears that. Without the set's own expiry it would accumulate a name for every category ever written and become the largest, slowest key on the server.

What still walks the keyspace, deliberately:

  • cache:clear with no category. It means everything, including entries written with no category at all, which are in no index. One deliberate sweep by an operator.
  • The "all items" listing on the cache dashboard, for the same reason. It uses SCAN rather than KEYS, so it cannot stall the instance.

getStats()' item count is summed from the category sets, so an entry written with no category is not counted. A number that is slightly low, over a tile that can stall production.

Turning a model's cache off — and when to

Model has two properties, both protected, and list caching is off by default:

class Reading extends \Pramnos\Application\Model
{
    protected $useCacheInLists = true;   // cache _getList / getCount / _getPaginated
    protected $cacheInListsTime = 60;    // seconds
}

$useCacheInLists = false is the switch, per model. Turn it off — or leave it off — on a write-heavy table: every row that changes invalidates every cached list for that table, because a list contains the row. Pay for the entry and throw it away before anything reads it, and you have added work for nothing. The cost is proportional to what the category holds rather than to the whole keyspace, but it is still a cost.

A rule of thumb rather than a number: caching a listing pays when the table is read far more often than it is written, and the reads are of the same listing. A per-user or heavily filtered list produces a distinct cache entry per variation, so it accumulates entries and serves few of them twice.

_load() is never cached — it passes false — so a single-row read by primary key is a database read every time, whatever these properties say.

When a change invalidates what

insert the whole category — a new row changes every list
update the whole category and the row's own key
delete both
a save() that changed nothing nothing, and no UPDATE either

That last row is the one worth knowing about: save() compares against what was loaded and returns early when nothing differs, so calling it liberally costs one comparison rather than a write and a flush.

An update clears the category as well as the row's own key because a cached list contains the row: clearing only the key would leave every list serving the old value until its TTL.

What invalidation costs

cacheflush($category) — which Model::save() calls, and Settings on every write — is O(the category), not O(the keyspace). Redis keeps a set of the keys in each category, and a flush reads that set and deletes its members.

That is worth stating plainly because the alternative — SCAN … MATCH over a pattern — walks the whole database on every call: MATCH filters what SCAN returns, not what it traverses. On the path of every save and every worker, that cost grows with everything else sharing the Redis database, and adding workers makes throughput worse rather than better, because they all queue on the same sweeps.

If you see this shape — workers waiting rather than computing, throughput falling as the pool grows — cat /proc/<pid>/wchan and redis-cli client list answer it in a minute where application timing cannot: the framework's execution_time measures wall clock around a handler, so a handler using a sliver of a core while waiting on redis looks exactly like a slow handler.

A keyspace sweep logs what it cost under cache when it takes longer than 250 ms, so the one place it happens — an explicit cache:clear with no category — says so rather than being inferred.

Leftover catindexed:* keys. These per-category marker keys are not read by anything. cache:clear removes them along with everything else; RedisAdapter::purgeCategoryMarkers() removes only the markers, for an installation that does not want to throw away a warm cache.

One connection, however many categories

Cache::getInstance() keeps an instance per category so cache:clear --category=views matches something. Those instances share one redis connection per endpoint — the category is a namespace for keys, not a reason for a second socket to the same server.

A socket per instance is invisible in a request — the process exits — and fatal in a daemon, where connections accumulate up to LimitNOFILE. With no descriptors left the autoloader cannot open a class file either, so PHP reports a class as missing that is sitting on disk.

If you write a redis client of your own, take the same route:

$redis = \Pramnos\Redis\ConnectionManager::forConfig([
    'host' => $host, 'port' => $port, 'database' => $db, 'password' => $password,
])->connection();

newConnection() is for a blocking SUBSCRIBE and nothing else — it opens a socket that nothing will close until the process ends.

Whose rows a cached query is

A cached query — ->get(true, $seconds, $category), User::load()'s userlist, the settings read — is stored under a prefix built from three things: the installation prefix from the cache settings, the table prefix, and the connection. The connection is its driver, its server:port, its database and, on PostgreSQL, its schema. The key itself is md5 of the statement and its bindings.

All of them, because a Redis is routinely shared: two sites on one server, one site's test and development databases, or staging and production. With the statement alone in the key, two databases running SELECT … FROM settings share one entry, and a user 42 on one answers for user 42 on the other for the TTL. The database name alone is not enough either: two databases with the same name on different servers, or on MySQL and PostgreSQL, would still share entries. The column cache is scoped the same way, by Database::columnCacheCategory().

Database::cacheflush($category) builds the same prefix, so a write on one database empties its own category and leaves the other's. Clear SQL entries through it rather than by matching keys — the key's shape is the framework's to change.

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 (merged over the `cache` setting)
$cache = \Pramnos\Cache\Cache::getInstance('sessions', 'session', 'file', [
    'prefix' => 'session_'
]);

getInstance() keeps one instance per (category, extension, method); the fourth argument is applied only when that instance is first built. For a different configuration of the same category, use new \Pramnos\Cache\Cache(...).

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

The reason to be strict about it: the constructor reads the cache setting first and then lets a non-empty method argument overwrite it. A caller that names a backend the server does not have ends up — after the fallback — on a private file cache sharing nothing with the store the rest of the application uses.

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');

The id is a string, and the framework says so if yours is not

load(), save(), delete() and increment() take the id untyped, for compatibility: they are public and overridable, so a string declaration would be a fatal at class load for any application that overrides one.

Pass a string anyway. An int, float or bool is converted the way it always was — an entry keyed by a row id keeps the name it has always had — but an array or an object is not a cache key:

$cache->load(['user' => $id]);            // don't
$cache->load('user_' . $id);              // do
$cache->load('user_' . md5(serialize($f))); // for a composite key, name it yourself

An array or object is hashed to a stable, distinct key, so such a call is at least correct — concatenating it would give every array-keyed call the literal name Array and one shared entry. The framework also writes one line per call site to the cache log naming the file and line that passed it:

Cache id is a array, not a string, from /var/www/app/Controllers/Foo.php:212 (category
"views"). It is hashed into a stable key, but until it is fixed there the entry is named
after a hash of the value rather than after anything meaningful…

Once per call site per process, because the point is to be read. Fix it at the caller: a key you chose is one you can find in the DevPanel cache browser (or getAllItems()), invalidate by name, and reason about when it goes stale.

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

The three instances above are distinct objects: getInstance() keeps one instance per (category, extension, method). That matters because $this->category is what goes into the cache key and save() has no category parameter — the instance's category is the only thing that puts an entry in a namespace cache:clear --category=… can match.

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.

The file adapter is told its category rather than recovering it from the key — the key is {category}_{id}, so splitting it on an underscore would file schema_columns_things entries under schema, where clear('schema_columns_things') would never find them. Redis, Memcached and the array store match the category against the key with a separator anchor.

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.

A pattern scan would 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 cost is 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 not 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 do not have this cost: Memcached cannot enumerate keys at all, and the file and array stores do not scan a keyspace.

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: the sampled sweep treats a timeout = 0 entry as permanent and never deletes it.

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:

// A scheduled task, every few hours
$removed = \Pramnos\Cache\Cache::getInstance()->cleanup();

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.
  • AdapterInterface does 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 — so Cache::cleanup() asks whether the adapter has the method and answers 0 when it does not. If you maintain an adapter and want the sweep, extend AbstractAdapter or add a public cleanup(): 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 memcached: what getAllKeys() returns, when the server allows it, otherwise one memcached_limitation info row; memcache: []
ttl seconds left, -1 for never seconds left, -1 for never memcached: always -1
an expired entry listed, marked expired, until a sweep removes it never listed: Redis evicts it —

Two details about Redis:

  • Categories come from the adapter's own index — catindex:<category> and catnames — the same source of truth clear() uses.
  • The item count is about the cache, not about Redis. It counts the entries under the cache's own prefix, not dbSize(), which would include sessions, queue payloads, another application's keys and the adapter's own bookkeeping.

MemcachedAdapter lists a category in its memcachedtags key the first time it tracks a key in it, and getCategories() reads that list. The legacy MemcacheAdapter tracks nothing, so its list is empty.

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.

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 answers "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.

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 — the directory is the CACHE_PATH constant, not a setting
$fileCache = new \Pramnos\Cache\Cache('category', 'extension', 'file');

// Memcached
$memcachedCache = new \Pramnos\Cache\Cache('category', 'extension', 'memcached', [
    'hostname' => 'memcached.example.com',
    'port' => 11211,
]);

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

A backend that cannot be used falls back to the local file cache — see When a cache that was working stops. Only with 'cache' => ['fallback' => true] in app/app.php does it walk the full ladder:

Redis → Memcached → Memcache → File
// Redis; on failure, File — or Memcached, Memcache, File with fallback enabled
$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:

Cache: falling back from "redis" to "file" - could not connect to 127.0.0.1:6379 (cross-technology fallback is off; set cache.fallback to enable it).

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:

if ($cache->method !== $cache->requestedMethod) {
    // Asked for one store, running on another.
}

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 installs redis or memcached into the generated Dockerfile when you pick that backend; an image built some other way needs the extension added, or the project runs 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);

See Categories and Organization for why an underscore in a category is safe.

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): int adds $by and returns the new total; when $ttl is given the expiry is reset on every call (a sliding window).
  • decrement(...) is the mirror image.
  • counter(string $key): int reads the current value — 0 when absent, and it does not create the key (unlike increment($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:

  • AdapterInterface is unchanged — existing third-party adapters keep working.
  • AbstractAdapter provides a concrete, non-atomic default (counter() + save()), so every adapter that extends it gains the capability for free.
  • RedisAdapter overrides it with native INCRBY / DECRBY + EXPIRE, making it genuinely atomic across processes.
  • If you inject a bare AdapterInterface without these methods, FlatCache transparently 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. false means "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 reports the File adapter as atomic and sends a 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/listRange follow 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; RedisAdapter uses a non-blocking SCAN, 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:

$adapter->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 limits apply on every adapter, the File adapter included. Deciding from the reader's argument alone would get both directions wrong: a structure saved with no expiry would vanish an hour after it was written if read with load()'s 3600 default, and a counter read with 0 would never expire, so a rate-limit window would never close.

Structured and counter reads pass 0: 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:

$cache->load('user_' . $somethingFromTheRequest);   // an id can be anything

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 \Pramnos\Http\Response::json($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() guards 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: … Failed to open directory.

Left uncaught, that would not break a cache flush; it would break whatever triggered the flush — User::save() flushes the user list, so a user activation would fail because of housekeeping that had already succeeded: the directory being gone is the state the flush wanted.

So the exception is caught 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. Every walk in the adapter goes through that one method.

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, it clears each category listed by getCategories() and logs how many. A key whose name carries no category is in no list and expires on its TTL. Other tenants' keys are untouched. To empty the server deliberately, call flushEverything(). (The legacy memcache adapter tracks no categories, so it refuses and says so.)
  • 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 behaves the same way: clear('') deletes <prefix>*, and flushes the database only when no prefix is set.

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:

  1. increment — succeeds if the counter exists, and that is the whole call.
  2. If it fails, the key is absent: add creates 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.
  3. If add fails too, another request created it in between. increment again, 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

  1. Cache Not Working
  2. Check if the cache backend is running
  3. Verify connection credentials
  4. Ensure proper file permissions for file cache

  5. Performance Issues

  6. Monitor cache hit rates
  7. Optimize cache key strategies
  8. Consider cache distribution across servers

  9. Memory Issues

  10. Set appropriate timeouts
  11. Implement cache size limits
  12. 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 on a dedicated server

// The adapter takes a host and port; there is no persistent-connection option
$memcachedCache = new \Pramnos\Cache\Cache('sessions', 'session', 'memcached', [
    'hostname' => 'memcached.example.com',
    'port' => 11211,
]);

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;

    public function __construct()
    {
        $this->primaryCache = \Pramnos\Cache\Cache::getInstance('primary', 'app', 'redis');
        $this->fallbackCache = \Pramnos\Cache\Cache::getInstance('fallback', 'app', 'file');
    }

    public function getWithFallback($key, $dataLoader = null)
    {
        try {
            $data = $this->primaryCache->load($key);
            if ($data !== false) {
                return $data;
            }
        } catch (\Exception $e) {
            \Pramnos\Logs\Logger::logError('Primary cache failed: ' . $e->getMessage());
        }

        try {
            $data = $this->fallbackCache->load($key);
            if ($data !== false) {
                return $data;
            }
        } catch (\Exception $e) {
            \Pramnos\Logs\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) {
            \Pramnos\Logs\Logger::logError('Primary cache save failed: ' . $e->getMessage());
        }

        try {
            $this->fallbackCache->timeout = $timeout;
            $this->fallbackCache->save($data, $key);
        } catch (\Exception $e) {
            \Pramnos\Logs\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.



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:

$cache = \Pramnos\Cache\FlatCache::default();
$cache->set('radio:now_playing', $data, 300);

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.