Cache::remember() covers most of what an application needs. The first time it is not enough is usually in production, under load, with a graph nobody can explain.
This is the part after the easy part.
The stampede
$popular = Cache::remember('posts:popular', 600, function () {
return Post::withCount('views')->orderByDesc('views_count')->limit(10)->get();
});Correct, and fine until the key is hot.
At second 600 it expires. Two hundred concurrent requests miss at the same instant, two hundred copies of that query hit the database together, and the response-time graph grows a spike every ten minutes that looks exactly like a scheduled job nobody can find.
One request should rebuild. The rest should wait for it, or serve something stale.
public function popular(): Collection
{
$value = Cache::get('posts:popular');
if ($value !== null) return $value;
$lock = Cache::lock('posts:popular:rebuild', 10);
if ($lock->get()) {
try {
$value = $this->computePopular();
Cache::put('posts:popular', $value, now()->addMinutes(10));
return $value;
} finally {
$lock->release();
}
}
// Someone else is rebuilding. Block for up to 5s waiting on their result,
// and only compute ourselves if they did not finish in time.
try {
$lock->block(5);
$lock->release();
return Cache::get('posts:popular') ?? $this->computePopular();
} catch (LockTimeoutException) {
return $this->computePopular();
}
}block() is the piece people miss. Without it the losing requests fall straight through to computing anyway, and you have written a lock that prevents nothing while looking like it prevents everything.
The pattern that avoids the wait altogether is a soft TTL. Store the value with a longer hard expiry plus a timestamp, serve it past its soft expiry, refresh in the background. Readers never block and the value is at most one refresh behind.
Jitter the TTLs
A deploy warms a thousand keys with 3600 seconds each. An hour later, all thousand expire inside the same second.
$ttl = 3600 + random_int(0, 300); // spread the cliff across five minutes
Cache::put($key, $value, $ttl);One line, and it turns a synchronised cliff into a slope. It is the cheapest reliability improvement available to any caching layer and it is almost never there by default.
Tags, with the price stated
Cache::tags(['posts', "author:{$id}"])->put("post:{$slug}", $post, 3600);
// Later: everything that author touches
Cache::tags(["author:{$id}"])->flush();Genuinely useful, and worth knowing what they cost before you spread them everywhere.
Each tagged write maintains a set per tag, so one write becomes several Redis operations. flush() invalidates everything carrying that tag, which means a tag that is too broad — ['posts'] on every key — turns a single edit into a full cache rebuild and a database spike. And tags are unavailable on the file and database drivers, so code written around them cannot run on either.
Keep tags coarse and few. If every key carries four of them, the bookkeeping is a meaningful share of the work you were trying to avoid.
Deciding what to cache
Two conditions, and you need both.
It has to be expensive to compute. A query the database answers in two milliseconds does not need a cache, and wrapping it in one adds a network hop, a serialisation step and an invalidation problem in exchange for nothing. Confirm which queries those actually are by reading the plan rather than by intuition, which is wrong about this surprisingly often.
And it has to be tolerable stale. If the value must be correct the instant it changes, a cache is a correctness bug with a delay fuse.
Aggregates and counts pass. Rendered fragments of rarely-changing content pass. External API responses pass, usually gratefully. Configuration assembled from several sources passes.
Per-user data with a low hit rate fails. Anything a user just edited and expects to see fails, loudly and in your inbox. Small lookups that were never slow fail, because they were never the problem.
Size is a cost too
Caching a large object graph per key eventually runs Redis into its memory limit, and then eviction starts making decisions on your behalf. Those decisions are rarely the ones you would have made.
// Convenient: the whole model graph, relations and all
Cache::put($key, $post->load('author', 'comments'), 3600);
// Cheaper: the fields the view actually renders
Cache::put($key, ['title' => $post->title, 'html' => $post->html], 3600);Watch used_memory and evicted_keys. Rising evictions mean the cache is full and silently dropping things, which surfaces as a falling hit rate and an unexplained increase in database load, in that order, about a week apart.
When the payloads themselves are the problem, compressing them buys room without touching a single key.
Two numbers
Cache::remember() is correct for most keys and insufficient for the hot ones. The additions worth making are small: a lock with a real block() on expensive rebuilds, jitter on TTLs, tags used sparingly, and an honest answer to why each key is cached at all.
Then watch hit rate and evictions. A falling hit rate means keys are expiring faster than they are read. Rising evictions mean Redis is full.
Both are visible long before they become a database problem. Neither is visible if nobody is looking, which is the usual arrangement.