Caching Strategies
Identify the distinct layers where caching happens in a PHP application, choose between cache-aside and write-through patterns, set a sound TTL strategy, and protect a cache from invalidation bugs and stampedes.
Caching is the single highest-leverage performance technique available to most PHP applications, because the fastest way to answer a request is to not do the expensive work at all — reuse an answer you already computed. But "just cache it" glosses over a surprising amount of design: which layer should hold the cached value, how does the cache get populated and kept fresh, how long should a value live before it's considered stale, and — the genuinely hard part — how do you make sure the cache doesn't quietly serve wrong data forever, or collapse under load the instant a popular key expires. This lesson works through the cache layers a PHP request actually passes through, the two dominant population patterns (cache-aside and write-through), TTL strategy, invalidation, and stampede protection.
The cache layers in a PHP application
A single request can pass through several distinct caches before it ever produces a response, each solving a different problem. OPcache caches PHP's compiled bytecode so the interpreter doesn't re-parse and re-compile the same source files on every request — this is essentially free performance every production PHP deployment should have enabled, and it caches code, not data. Application-level caching (an array, APCu, or a dedicated store) holds computed values your own code produces — a rendered template fragment, a parsed configuration, a database query result. Database-level caching lives inside the database itself (a query cache or buffer pool holding recently-accessed pages) and is largely out of the application's direct control. HTTP caching operates a layer up from all of this, using Cache-Control/ETag headers so browsers, CDNs, and reverse proxies (Varnish, nginx) can serve a response without the PHP process running at all for that request.
These layers are complementary, not competing: OPcache and HTTP caching are close to "turn it on and get it for free" wins, while application-level caching (often backed by Redis or Memcached — see the next lesson) is where most of the deliberate design work in this lesson applies, because you choose exactly what gets cached, for how long, and how it's invalidated.
• OPcache: caches compiled bytecode, not data — avoids re-parsing PHP source on every request. • Application cache (APCu/Redis/Memcached/array): holds computed values your code decides to store — the main focus of this lesson. • Database query/buffer caching: happens inside the database engine, mostly outside direct application control. • HTTP caching (Cache-Control/ETag + CDN/reverse proxy): can serve a response without PHP running at all for that request.
Cache-aside vs. write-through
Cache-aside (also called lazy loading) is the most common pattern: application code checks the cache first, and on a miss, reads from the source of truth (the database), stores the result in the cache, and returns it. Every subsequent request for that key hits the cache until it expires or is invalidated. This pattern is simple, only caches data that's actually requested (no wasted cache space on unused rows), and tolerates a cold or flushed cache gracefully — the next request just takes the slow path once and repopulates it.
Write-through keeps the cache updated at write time instead of waiting for the next read to discover it's stale: every write to the database is paired with an immediate write to the cache, so a read can always trust the cache is current (subject to the TTL still being respected as a safety net). This trades a small amount of extra latency on every write for the guarantee that reads never see a stale value between a write and the next cache population — valuable for data where staleness is unacceptable even for a few seconds, at the cost of caching data that might never actually be read again.
• Cache-aside: read path checks cache → miss → read DB → populate cache → return. Simple, only caches what's actually requested. • Write-through: write path updates DB and cache together, so reads are current without waiting for a cache-populating read to happen first. • Cache-aside tolerates a cold cache gracefully; write-through avoids a stale window between a write and the next read. • A third pattern, write-behind (write to cache immediately, flush to the database asynchronously later), trades durability risk for write latency and is used more sparingly.
TTL strategy
Every cached value should have a time-to-live (TTL) as a baseline safety net, even data you also invalidate explicitly on write — explicit invalidation logic can have bugs or miss a code path, and a TTL guarantees the cache self-heals within a bounded window regardless. The right TTL depends on how volatile the data is and how costly staleness is: a product catalog page might tolerate a five-minute TTL just fine, while an account balance shown right after a payment usually needs either a very short TTL or explicit invalidation on write, or both.
A subtle trap with TTLs is synchronized expiration: if many related keys are all populated at the same moment (say, at application deploy or a cache warm-up) and all given the same TTL, they all expire at the same moment too, producing a spike of simultaneous cache misses. Adding a small amount of random jitter to each TTL (e.g. base TTL ± 10%) spreads expirations out over time instead of letting them all land in the same instant.
• Set a TTL on every cached value, even ones you also invalidate explicitly, as a self-healing safety net. • Match TTL length to data volatility and the cost of serving stale data for that key. • Add small random jitter to TTLs so related keys don't all expire in the same instant. • A shorter TTL is a cheap way to bound the blast radius of any invalidation bug you haven't found yet.
Cache invalidation
There's a well-worn line in computer science: "there are only two hard things — cache invalidation and naming things." The difficulty is real: a TTL alone guarantees staleness only up to a bound, but many use cases need a cached value to become fresh the instant the underlying data changes, not merely within some window. The direct approach is explicit invalidation — when code writes new data, it also deletes (or overwrites) the corresponding cache key(s) — but this requires the write path to know about every cache key that could be derived from the data it's changing, which gets harder as more of an application starts caching more things derived from the same underlying rows.
Two techniques make this more tractable at scale. Versioned keys (or "cache tags") embed a version number in the cache key itself — bumping a single version counter effectively invalidates every key built from it at once, without deleting each one individually. Event-driven invalidation decouples invalidation from the specific code path that happened to write the data, by publishing a domain event ("OrderUpdated") that any interested cache listens for and reacts to, so a new write path added later doesn't silently forget to invalidate a cache it doesn't know exists.
• TTL bounds staleness; explicit invalidation on write makes staleness disappear immediately for keys the write path knows about. • Versioned/tagged keys let you invalidate a whole family of related cache entries by bumping one version number. • Event-driven invalidation decouples 'something changed' from 'which caches need to know', reducing the risk of a forgotten cache.
Cache stampede protection
A cache stampede (also called a dogpile) happens when a popular key expires and a burst of concurrent requests all miss the cache at the same instant — every one of them proceeds to recompute the value independently, often by hitting the same expensive database query simultaneously, which can multiply the load on the database by however many requests arrived in that window and, in the worst case, take the database down entirely from a single key's expiry.
The standard fix is a lock: the first request to miss the cache acquires a short-lived lock (commonly via a Redis SET key value NX — set only if not already set), recomputes the value, populates the cache, and releases the lock, while every other concurrent request either waits briefly and retries the cache read, or serves a slightly-stale value if one is available rather than also hitting the database. A complementary technique, probabilistic early expiration (sometimes called XFetch), has requests recompute a value slightly before its TTL actually expires, with a probability that increases as the expiry approaches — spreading recomputation out across time so it's vanishingly unlikely many requests all decide to recompute at exactly the same instant.
• Stampede: many concurrent requests miss an expired key at once and all hit the database simultaneously. • Locking (Redis SET NX): only the first miss recomputes; others wait or serve stale data instead of also hitting the database. • Probabilistic early expiration: recompute slightly before TTL expiry with rising probability, so recomputation is spread out instead of synchronized. • Both techniques matter most for 'hot' keys — data read by a very large number of requests relative to how often it changes.
Code Examples
The function checks the cache first and only queries the database on a miss, then populates the cache before returning — the textbook cache-aside flow. The small random jitter added to the 300-second base TTL means that if many user profiles were cached around the same time (e.g. after a deploy), they won't all expire in the same instant.
$cached = apcu_fetch($key, $found);
Common mistake: Storing the value with apcu_store($key, $profile) and no TTL argument caches it indefinitely (until APCu's memory limit forces eviction), so a later update to that user's row in the database would never be reflected unless something explicitly deletes the key.
Rather than waiting for the cache to expire and be lazily repopulated on the next read (which would serve a stale email for up to the TTL window), the write path immediately refreshes the cache entry as part of the same operation, so any read that follows this write sees the new email right away.
apcu_store("user_profile:{$userId}", $profile, 300);
SET key value NX EX 10 atomically sets the lock only if it doesn't already exist, so exactly one of many concurrent requests wins the race and proceeds to run the expensive query; every other request that arrives while the lock is held backs off and retries the cache instead of also running the query, preventing the database from being hit once per concurrent request.
$acquiredLock = $redis->set($lockKey, '1', ['NX', 'EX' => 10]);
Common mistake: Skipping the lock and having every request independently recompute on a cache miss works fine under low concurrency but means a popular key's expiry under real load sends a burst of identical, simultaneous queries at the database — exactly the stampede this pattern exists to prevent.
<?php
declare(strict_types=1);
function getUserProfile(int $userId, PDO $db): array
{
$key = "user_profile:{$userId}";
$cached = apcu_fetch($key, $found);
if ($found) {
return $cached;
}
// Miss: read the source of truth.
$stmt = $db->prepare('SELECT id, name, email FROM users WHERE id = ?');
$stmt->execute([$userId]);
$profile = $stmt->fetch(PDO::FETCH_ASSOC);
// Populate the cache with a TTL (+ small jitter) before returning.
$ttl = 300 + random_int(-20, 20);
apcu_store($key, $profile, $ttl);
return $profile;
}<?php
declare(strict_types=1);
function updateUserEmail(int $userId, string $newEmail, PDO $db): void
{
$db->prepare('UPDATE users SET email = ? WHERE id = ?')
->execute([$newEmail, $userId]);
// Write-through: refresh the cache in the same operation,
// so the next read never sees the stale email.
$stmt = $db->prepare('SELECT id, name, email FROM users WHERE id = ?');
$stmt->execute([$userId]);
$profile = $stmt->fetch(PDO::FETCH_ASSOC);
apcu_store("user_profile:{$userId}", $profile, 300);
}<?php
declare(strict_types=1);
function getExpensiveReport(Redis $redis, PDO $db): array
{
$key = 'daily_report';
$lockKey = 'daily_report:lock';
$cached = $redis->get($key);
if ($cached !== false) {
return json_decode($cached, true);
}
// Try to become the single request that recomputes.
$acquiredLock = $redis->set($lockKey, '1', ['NX', 'EX' => 10]);
if (!$acquiredLock) {
// Someone else is already recomputing — wait briefly and retry
// the cache once instead of also hitting the database.
usleep(200_000);
$cached = $redis->get($key);
return $cached !== false ? json_decode($cached, true) : [];
}
$report = runExpensiveReportQuery($db); // the costly path
$redis->set($key, json_encode($report), ['EX' => 60]);
$redis->del($lockKey);
return $report;
}Why this matters in production
Caching bugs rarely show up as crashes — they show up as a support ticket saying 'I updated my profile but it still shows the old name', filed hours after the fix should have taken effect, which is exactly the failure mode of a cache with no TTL and a missed invalidation path. On the opposite end, a poorly protected hot cache key (a homepage banner, a pricing table) can take an entire database down the moment it expires under real traffic — a stampede that looks, from the database's perspective, indistinguishable from a denial-of-service attack. Both failure modes are avoidable with the same short list of disciplines: always set a TTL, invalidate explicitly where staleness matters, and protect hot keys with a lock or probabilistic early expiration.
Populate a cache entry once and never invalidate or set a TTL, relying on developers to remember to clear it manually whenever the underlying data changes.
Always set a TTL as a safety net, and invalidate the specific key explicitly (or via a version bump) at the moment the underlying data changes.
A cache with no TTL and no explicit invalidation path will serve stale data forever the first time someone forgets to manually clear it — which happens as soon as more than one write path touches the underlying data. A TTL bounds the damage even when explicit invalidation is missed.
What is the key difference between cache-aside and write-through caching? A. Cache-aside is only for Redis; write-through is only for Memcached B. In cache-aside, the cache is populated lazily on a read miss; in write-through, the cache is updated immediately as part of every write C. Write-through never expires cached values; cache-aside always does D. They are two names for the exact same pattern
B. In cache-aside, the cache is populated lazily on a read miss; in write-through, the cache is updated immediately as part of every write — Cache-aside is a read-time pattern (check cache, miss, read source, populate cache), while write-through updates the cache proactively at write time so reads are always current without needing a populating read first.
Why should a cached value have a TTL even if the application also explicitly invalidates it on every write? A. TTLs make cache reads faster than reads without a TTL B. A TTL is a self-healing safety net that bounds staleness even if an invalidation code path is missed or has a bug C. PHP requires every cache entry to have a TTL or it refuses to store it D. TTLs are only relevant for OPcache, not application-level caches
B. A TTL is a self-healing safety net that bounds staleness even if an invalidation code path is missed or has a bug — Explicit invalidation depends on every write path correctly knowing which cache keys to clear, which can be missed as an application grows; a TTL guarantees the cache converges back to fresh data within a bounded window regardless of any missed invalidation.
What problem does locking a cache-recompute (e.g. Redis SET key value NX) solve? A. It makes the cached value never expire B. It prevents a cache stampede by ensuring only one request recomputes an expensive value while concurrent requests wait or serve stale data instead of all hitting the database at once C. It automatically invalidates related cache keys D. It reduces the TTL needed for a cached value
B. It prevents a cache stampede by ensuring only one request recomputes an expensive value while concurrent requests wait or serve stale data instead of all hitting the database at once — SET ... NX atomically ensures only the first request to miss the cache acquires the lock and recomputes, while other concurrent requests back off instead of independently running the same expensive operation — directly preventing a stampede on the underlying data source.
A product listing page caches getPopularProducts() for 10 minutes with no jitter and no stampede protection. Traffic analysis shows this key gets roughly 200 requests per second at peak, and the underlying query takes 800ms to run. Describe what happens at the exact moment the cached key expires under this load, and redesign the caching call to prevent it.
At 200 req/s and an 800ms recompute time, roughly 160 requests would arrive and all miss the cache before the first recomputation finishes, and every one of them would independently run the 800ms query — a stampede of ~160 simultaneous identical queries hitting the database at once. Fix: wrap the recompute in a Redis SET NX lock so only the first miss runs the query while the rest wait briefly and retry the cache (or serve a slightly stale cached copy), and add jitter to the TTL so this key doesn't tend to expire at a perfectly predictable, synchronized moment relative to other cached keys.
Consider how many requests arrive in the 800ms it takes to recompute the value if every one of them independently notices the cache miss and starts its own query.