Caching
How to cache an expensive result: choosing a key, setting an expiry, APCu against Redis, and deleting the key when the data changes.
apcu_entry(string $key, callable $callback, int $ttl = 0): mixedA cache stores the answer to something expensive so you do not pay for it twice. Expensive means a query that takes 400 milliseconds, a request to another server, or a page assembled from a dozen rows. You compute the value once, keep it under a key, and read it back until it is too old to trust.
Key, value, expiry
The key must identify everything the value depends on. sales:total:2026-09 is a usable key. sales is not, because the month changes and you then serve the wrong number. The expiry, usually called the TTL (time to live), is a number of seconds after which the entry is dropped and recomputed. A short TTL is the cheapest form of correctness you can buy.
One machine or several
APCu keeps values inside the PHP worker processes on one machine. It is the fastest option and needs nothing else running, but each machine holds its own copy and a restart empties it. Redis is a separate server that every machine talks to, so there is one copy and it survives a deploy, at the cost of a network round trip per read. Neither is bundled with PHP; both are extensions you install.
Invalidation
You must be able to delete a key on demand. The code that records a sale also deletes sales:total:2026-09, after the transaction commits. If you cannot name the keys a write affects, do not cache that value for long; put a version number or a last-changed timestamp in the key and let the old entries expire.
Example
When to use it
- Serving a dashboard total that takes seconds to compute without running the query on every page view.
- Holding the response from a third-party API so a slow or rate-limited service does not slow your own pages.
- Deciding between APCu and Redis when a second web server is about to be added.
- Clearing exactly the right cache entries when an editor updates a product.
More examples
The longhand and the shorthand with APCu
These two functions do the same thing, and recognising the short form in other people's code matters. apcu_fetch takes its success flag by reference, which is how you tell a stored 0 or false from a miss; testing the returned value alone recaches a legitimately falsy result forever. apcu_entry holds APCu's lock while your callback runs, so the callback must stay short and must not call other apcu_ functions. Both versions keep the prepared statement, so the month is bound rather than pasted into the SQL.
<?php
declare(strict_types=1);
// Longhand: look, miss, build, store.
function monthlyTotal(PDO $pdo, string $month): int
{
$key = "sales:total:{$month}";
$hit = apcu_fetch($key, $found);
if ($found)
{
return (int) $hit;
}
$statement = $pdo->prepare('SELECT COALESCE(SUM(amount), 0) FROM sales WHERE month = ?');
$statement->execute([$month]);
$total = (int) $statement->fetchColumn();
apcu_store($key, $total, 300);
return $total;
}
// Shorthand: apcu_entry performs the same three steps for you.
function monthlyTotalShort(PDO $pdo, string $month): int
{
return apcu_entry(
"sales:total:{$month}",
static function () use ($pdo, $month): int {
$statement = $pdo->prepare('SELECT COALESCE(SUM(amount), 0) FROM sales WHERE month = ?');
$statement->execute([$month]);
return (int) $statement->fetchColumn();
},
300
);
}Redis, with the key version in the name
Reach for this when more than one web server serves the same pages, because they then share one cache. Values cross the network as text, so they are encoded; JSON_THROW_ON_ERROR turns a bad encode into an exception rather than a silent false. The v2 in the key is a deploy tool: when the stored shape changes, bump it and every old entry is ignored at once, with no flush and no stale rows surviving the release. setex stores the value and its expiry in one call.
<?php
declare(strict_types=1);
final class ProductCache
{
public function __construct(
private Redis $redis,
private PDO $pdo,
) {
}
public function find(int $id): ?array
{
$key = "product:v2:{$id}";
$cached = $this->redis->get($key);
if ($cached !== false)
{
return json_decode($cached, true, flags: JSON_THROW_ON_ERROR);
}
$statement = $this->pdo->prepare('SELECT id, name, price FROM products WHERE id = ?');
$statement->execute([$id]);
$row = $statement->fetch(PDO::FETCH_ASSOC) ?: null;
if ($row !== null)
{
$this->redis->setex($key, 600, json_encode($row, JSON_THROW_ON_ERROR));
}
return $row;
}
public function forget(int $id): void
{
$this->redis->del("product:v2:{$id}");
}
}Deleting the key in the write path
This is the half of caching that people skip, and the reason a user updates a price and still sees the old one. The order is the whole point. Delete inside the transaction and a concurrent read can refill the key from the pre-commit state. Delete after a rollback and you have thrown away a valid entry for nothing. If a write touches a list as well as a row, the list key has to go too, which is the practical argument for naming keys so you can work out which ones a change affects.
<?php
declare(strict_types=1);
function recordSale(PDO $pdo, ProductCache $cache, int $productId, int $amount): void
{
$pdo->beginTransaction();
try
{
$statement = $pdo->prepare('INSERT INTO sales (product_id, amount) VALUES (?, ?)');
$statement->execute([$productId, $amount]);
$pdo->commit();
}
catch (PDOException $e)
{
$pdo->rollBack();
throw $e;
}
// After the commit, never before: a rolled back write must leave the
// cache alone, and a key deleted too early is refilled with old data.
$cache->forget($productId);
}
Discussion