Cache
Anything expensive enough to hurt when it runs twice belongs in a cache — a rendered fragment, an API
response, an aggregate query. pop-cache puts one API in front of eight storage backends, so the call
sites you write against a file cache on a laptop keep working against Redis in production. You pick the
backend once, when you construct the object, and nothing downstream changes.
composer require popphp/pop-cache
Getting Started#
A cache is a Pop\Cache\Cache wrapped around an adapter. The adapter decides where items live and carries
the global time-to-live, in whole seconds.
use Pop\Cache\Cache;
use Pop\Cache\Adapter\File;
$cache = new Cache(new File(__DIR__ . '/../cache', 300));
$cache->saveItem('user-1001', ['id' => 1001, 'name' => 'John Smith']);
$cache->getItem('user-1001'); // the array back
$cache->hasItem('user-1001'); // true
$cache->deleteItem('user-1001');
saveItem() takes an optional third argument to override the global TTL for one item. A TTL of 0 means
the item never expires, which is also the default every adapter constructor uses when you omit it — pass a
number of seconds unless you mean forever.
Create the File adapter's directory, writable, before constructing it — File calls setDir() from its
constructor, so a missing or unwritable path throws Pop\Cache\Adapter\Exception at bootstrap rather than
at the first write.
getItem() returns false for a miss, which is ambiguous the moment you cache a boolean. Pass a second
argument and that value is returned on a genuine miss instead:
use Pop\Cache\Cache;
use Pop\Cache\Adapter\Memory;
$cache = new Cache(new Memory());
$cache->saveItem('feature-enabled', false);
$cache->getItem('feature-enabled', null); // false — the real cached value
$cache->getItem('never-cached', null); // null — a genuine miss
Keys are validated. {, }, (, ), /, \, @ and : are the characters PSR-16 reserves, and an empty
key is rejected under a rule of its own; all of them throw Pop\Cache\InvalidArgumentException on the way in
with the same message. The colon is the one that
catches people out, since user:1001 is the house style in other caching libraries — write user-1001 here.
Choosing an Adapter#
Every adapter implements Pop\Cache\Adapter\AdapterInterface, so swapping one for another is a one-line
change at construction.
| Adapter | Constructor | Reach for it when |
|---|---|---|
File |
new File($dir, $ttl) |
A single server with a writable disk. The default choice. |
Redis |
new Redis($ttl, $host, $port) |
Several web servers share one cache, and you want persistence. |
Memcached |
new Memcached($ttl, $host, $port) |
Several web servers share one cache, purely volatile. |
Apc |
new Apc($ttl) |
One server, hottest possible reads — APCu lives in the PHP process. |
Database |
new Database($db, $ttl) |
A pop-db connection is all the shared storage you have. |
Session |
new Session($ttl) |
Per-user data for the length of one visit. |
Memory |
new Memory($ttl) |
Tests, and caching within a single request. |
NullAdapter |
new NullAdapter() |
Turning caching off without touching a call site. |
The Database adapter takes a Pop\Db\Adapter\AbstractAdapter as its first argument and creates its
pop_cache table from its constructor, before anything is cached. Redis, Memcached, Apc and Session
each accept a $namespace argument
(default pop_cache), which is what keeps clear() from wiping a neighboring application sharing the same
backend.
Two static methods report what the current runtime can support, which is how you pick without hardcoding an assumption about the deployment:
use Pop\Cache\Cache;
use Pop\Cache\Adapter\File;
use Pop\Cache\Adapter\Redis;
$cache = Cache::isAvailable('redis')
? new Cache(new Redis(300))
: new Cache(new File(__DIR__ . '/../cache', 300));
Cache::getAvailableAdapters() returns the whole map, keyed apc, file, memcached, memory, null,
redis, session and sqlite, with memory, null and file always true. The Database adapter is
reported under sqlite, and that key answers a narrower question than it looks: the probe is
class_exists('Sqlite3') or sqlite among the PDO drivers, so it says nothing at all about a MySQL- or
PostgreSQL-backed Database adapter.
getAvailableAdapters() reports what the PHP runtime has loaded rather than whether a backing service
answers, so confirm the connection itself before relying on Redis, Memcached or APCu.
Computing on a Miss#
remember() collapses the check-compute-store dance into one call. On a hit it returns the cached value; on
a miss it runs the callback, caches what comes back, and returns it.
use Pop\Cache\Cache;
use Pop\Cache\Adapter\File;
$cache = new Cache(new File(__DIR__ . '/../cache', 300));
$report = $cache->remember('sales-report', function () {
return ['total' => 4820]; // runs only on a miss
}, 300, 1.0);
A falsy return — false, null, 0, '' — is cached correctly and does not send the callback round again.
If the callback throws, the exception propagates unchanged and nothing is written.
The fourth argument, $beta, defaults to 0.0 and turns on probabilistic early recomputation above that.
As an item nears its TTL each read has a growing chance of refreshing it early, so a rebuild usually falls
to one caller while everyone else is served the still-valid copy. 1.0 is a sensible starting point. It
protects the steady state rather than a cold key, which still lets every concurrent caller compute at once.
Bulk Invalidation with Tags#
When one change invalidates a set of keys you cannot enumerate, tag them on the way in and invalidate the tag.
use Pop\Cache\Cache;
use Pop\Cache\Adapter\Memory;
$cache = new Cache(new Memory());
$cache->saveTaggedItem('product-1', 'widget', ['products', 'category-electronics']);
$cache->saveTaggedItem('product-2', 'novel', ['products', 'category-books']);
$cache->invalidateTag('category-electronics'); // deletes product-1, leaves product-2
$cache->invalidateTags(['products', 'category-books']);
Tag names go through the same reserved-character rule as keys, so category:electronics throws. Re-saving an
id with a different tag list moves it correctly between tags.
Once a key is tagged, keep writing it through saveTaggedItem() so the tag index stays in step.
PSR-16 and PSR-6#
Pop\Cache\Cache implements Psr\SimpleCache\CacheInterface directly, so an instance you already have is a
valid PSR-16 cache — get(), set(), has(), delete(), clear() and the *Multiple() methods sit
alongside the *Item() names and read the same values.
use Pop\Cache\Cache;
use Pop\Cache\Adapter\File;
use Psr\SimpleCache\CacheInterface;
function warmUp(CacheInterface $cache): void
{
$cache->set('greeting', 'Hello', 300);
}
warmUp(new Cache(new File(__DIR__ . '/../cache', 300)));
A TTL of 0 means "forever" to saveItem() and "delete" to set(), which is what PSR-16 mandates.
Pick one family per call site.
PSR-6 cannot live on the same class, because it declares getItem() and friends with incompatible
signatures, so it ships as Pop\Cache\Psr6\CacheItemPool wrapping the same adapters:
use Pop\Cache\Adapter\File;
use Pop\Cache\Psr6\CacheItemPool;
$pool = new CacheItemPool(new File(__DIR__ . '/../cache', 300));
$item = $pool->getItem('config-hash');
if (!$item->isHit()) {
$item->set('abc123');
$item->expiresAfter(300);
$pool->save($item);
}
Build both around one adapter instance when an application needs the native API and a PSR-6 pool at once.
Adapter-specific behavior goes well past this page — the atomicity and TTL differences between counter
implementations, what destroy() tears down on each backend, how cached objects survive serialization, and
the clock you can inject for deterministic TTL tests — see the pop-cache README.
See Also#
- Logging — the other write-through component with a swappable adapter set
- Sessions & Cookies — what the
Sessionadapter is writing into - pop-cache README — the full API surface, adapter by adapter