Skip to content
4 min read

Laravel caching with Redis, past the basics

Cache::remember is the easy part. Stampedes, tags, TTL jitter, locks and what actually belongs in the cache on a busy Laravel application.

On this page
  1. The stampede
  2. Jitter the TTLs
  3. Tags, with the price stated
  4. Deciding what to cache
  5. Size is a cost too
  6. Two numbers

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

php
$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.

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

php
$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

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

php
// 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.

Frequently asked

What is a cache stampede and how do I prevent it?

It is what happens when a popular key expires and every concurrent request misses at the same moment, so all of them run the expensive query together. Laravel's Cache::lock lets one request rebuild the value while the others wait briefly or serve a stale copy, turning a thundering herd into a single recomputation.

Should I use cache tags in Laravel?

They are genuinely useful for invalidating a group of related keys, and they are not free. Each tagged write maintains a set per tag, flushing a tag invalidates everything carrying it, and the feature is unavailable on the file and database drivers. On Redis they work well as long as tags stay coarse and deliberate.

How long should a Laravel cache TTL be?

As long as the data can be stale without anyone minding, which is a product question more than a technical one. The more important detail is to vary it: identical TTLs on thousands of keys written in the same deploy expire together and produce a synchronised miss across the whole set.

What should not be cached?

Anything cheap to query, anything that must be correct the instant it changes, and anything per-user that would blow up the key space for a low hit rate. A cache entry that is read once before it expires has cost you memory and a write and saved nothing.

Found this useful?Share on XShare on LinkedIn

Related reading