All posts

Caching in Laravel without the stampede

Published July 4, 2026 · by Majd Ghithan, written with the help of AI

Caching in Laravel without the stampede

We cached the homepage's "trending products" query for five minutes. It was our slowest query — about 900ms, a big aggregation across orders. Caching it took the homepage from sluggish to instant. Ship it, celebrate, move on.

Then, every five minutes, exactly on the minute, the database CPU spiked to 100% for a second or two. Errors, timeouts, then back to calm. It took me an embarrassingly long time to connect it to the cache TTL. The cache wasn't protecting the database. It was scheduling a database attack every five minutes.

That's a cache stampede, and if you cache anything expensive under real traffic, you will meet it.

Why the stampede happens

A cached value has a TTL. When it expires, the next request that asks for it gets a miss and rebuilds it. In a demo, that's one request. Under load, it's not.

Picture 500 requests per second hitting that homepage. The cache expires at 12:05:00. The request at 12:05:00.001 misses and starts the 900ms rebuild. But so does the request at .002, and .050, and every single request for the next 900 milliseconds — because none of them see a cached value yet, the first rebuild hasn't finished. So instead of one expensive query, you fire 450 identical expensive queries at once, all computing the exact same thing, all hammering the database that the cache was supposed to shield.

DB queries per second around a cache expiry
11450212:04:5812:04:5912:05:00 (expiry)12:05:01
queries/s

The cache didn't reduce your database load. It concentrated it into one-second spikes and hid it from you the rest of the time. This is also called a dogpile — everyone piles onto the rebuild at once.

The fix: one rebuild, everyone else waits or serves stale

The naive rebuild and the safe rebuild look almost identical in code. The difference is a lock.

Naive — every miss rebuilds
$data = Cache::remember('trending', 300, function () {
    return $this->expensiveAggregation();
});

// 450 concurrent misses
// = 450 rebuilds
// = DB on fire
Lock-protected — one rebuild
$lock = Cache::lock('trending:rebuild', 10);

if ($lock->get()) {
    try {
        $data = Cache::remember('trending', 300,
            fn () => $this->expensiveAggregation());
    } finally {
        $lock->release();
    }
} else {
    // someone else is rebuilding — serve last known value
    $data = Cache::get('trending:last', []);
}

Cache::lock() gives you an atomic lock across all your workers (backed by Redis or Memcached). Only one request wins the lock and rebuilds. The other 449 don't queue up behind an expensive query — they take the fast path and serve the last good value. One database query instead of 450.

Stale-while-revalidate: nobody ever waits

The lock still means one unlucky request pays the 900ms rebuild cost. You can do better: serve the stale value to everyone, including the one that triggers the refresh, and rebuild in the background.

Laravel's Cache::flexible() does exactly this. You give it two durations — a fresh window and a stale window:

$data = Cache::flexible('trending', [300, 600], function () {
    return $this->expensiveAggregation();
});

For the first 300 seconds the value is fresh and served instantly. Between 300 and 600 seconds it's stale but still served instantly — and the request that noticed it's stale kicks off a background refresh (via a deferred callback) instead of blocking. After 600 seconds it's fully expired and behaves like a normal miss. In practice almost every request gets an instant response and the rebuild happens off to the side. This is the pattern I reach for first now.

Staggered TTLs stop synchronized expiry

Even with locks, if you cache 50 keys that all expire at exactly the same second — say you warmed them all in one loop — they'll all stampede together. Add a little jitter so they don't expire in lockstep:

$ttl = 300 + random_int(0, 60); // 5 min, spread across a 60s window
Cache::put($key, $value, $ttl);

Now the expiries smear across a minute instead of detonating on the same tick. It's one line and it turns a synchronized spike into a flat line.

Invalidate with tags, not a sledgehammer

When the underlying data changes, you often need to drop several related keys at once. Cache::flush() nukes everything, including sessions and unrelated caches. Tags let you invalidate a group surgically:

Cache::tags(['products'])->remember('trending', 300, fn () => ...);
Cache::tags(['products'])->remember('featured', 300, fn () => ...);

// a product changed — drop only product caches
Cache::tags(['products'])->flush();

Note tags need a taggable store — Redis or Memcached, not the file or database driver. If you're on file in production for a cache this important, that's the first thing to fix.

The mistake isn't caching. It's assuming a TTL is the whole story. A plain remember() with a TTL is a cache that works perfectly right up until enough people use it at once — which is exactly the moment you added the cache for. Protect the rebuild, and the spike flattens into the smooth line you thought you shipped in the first place.

🤖 Heads up: this post was drafted with AI. Spot something wrong or off?Edit it on GitHub & open a PR
Majd Ghithan

Majd Ghithan

Full-Stack Engineer & Tech Lead

More posts

Laravel finally resizes images for you

Laravel finally resizes images for you

Every Laravel app I've built that takes an avatar ended the same way: composer require intervention/image, wire up a facade, write a little service class, and hope the next dev…

July 26, 2026Read more
What actually breaks at 100,000 users (that never breaks in a demo)

What actually breaks at 100,000 users (that never breaks in a demo)

The first time I shipped a dashboard to a hundred thousand active users, nothing I had tested was what broke. Everything that failed had passed every check I ran. It worked on m…

July 25, 2026Read more
Where your queue jobs go to die under load

Where your queue jobs go to die under load

The support ticket said "I never got my invoice email." I checked the logs. The job ran. It succeeded. failedjobs was empty. Everything said the email went out. It did not.

July 18, 2026Read more
The indexes you're missing (and the one that's hurting you)

The indexes you're missing (and the one that's hurting you)

A client sent me a query that took 4.2 seconds. One WHERE, one ORDER BY, a table with three million rows. I added a single index and it dropped to 11 milliseconds. They asked if…

July 11, 2026Read more
Taking one endpoint from 800ms to 80ms

Taking one endpoint from 800ms to 80ms

The order-details endpoint took 800 milliseconds. Not broken, just slow enough that the app felt heavy everywhere it was used. The team's instinct was "the server needs more mem…

June 27, 2026Read more
The N+1 you can't see (it's hiding in your accessors)

The N+1 you can't see (it's hiding in your accessors)

I've fixed hundreds of N+1 queries. The easy ones are right there in the controller — a foreach with $order-customer inside it, obvious the moment you read the code. Those aren'…

June 20, 2026Read more
The Friday deploy that charged customers twice

The Friday deploy that charged customers twice

It was 4:40 on a Friday. The change was tiny — a one-line tweak to how we called the payment provider, plus a bump to the queue worker's timeout. Small, tested, reviewed. I depl…

June 13, 2026Read more
Code review people don't dread

Code review people don't dread

I once left forty-one comments on a junior's pull request. I was proud of it. Thorough, I told myself. The next day he barely made eye contact, and his next PR sat open for a we…

June 6, 2026Read more
Hiring a mid-level Laravel dev: the signals that actually matter

Hiring a mid-level Laravel dev: the signals that actually matter

The best hire I ever made failed my first question. I asked him to explain service containers and he stumbled, went quiet, then said "honestly I use them every day but I've neve…

May 30, 2026Read more
Saying no to a feature without being the 'no' person

Saying no to a feature without being the 'no' person

For about a year I was the engineer everyone learned to route around. Not because I was wrong, I was usually right, but because my answer to new ideas was a flat "no, that'll br…

May 23, 2026Read more
My first 90 days as a tech lead (and the habit I had to break)

My first 90 days as a tech lead (and the habit I had to break)

Three weeks into leading my first team, I stayed late to "help" by rewriting a junior's feature myself. It was faster my way. I pushed it, felt productive, went home. The next m…

May 16, 2026Read more
Breaking down the task that scares you

Breaking down the task that scares you

There's a specific kind of ticket that makes my stomach drop. Not the hard ones, hard is fine. It's the ambiguous ones. "Migrate billing to the new provider." "Add multi-tenancy…

May 9, 2026Read more
1:1s that aren't just status updates

1:1s that aren't just status updates

For my first few months as a lead, my 1:1s were thirty minutes of me asking "so, what are you working on?" and nodding at answers I already knew from standup. We both left sligh…

May 2, 2026Read more
Get the business logic out of your controllers

Get the business logic out of your controllers

I once opened a OrderController@store method that was 240 lines long. Validation, a discount calculation, three model writes, a Stripe charge, two emails, a Slack notification,…

April 25, 2026Read more
Form Requests are the most underrated thing in Laravel

Form Requests are the most underrated thing in Laravel

Most Laravel devs meet Form Requests once, in a tutorial, use them to hold a rules() array, and never look deeper. That's a shame, because a Form Request is the cleanest place i…

April 18, 2026Read more
Testing Laravel without mocking everything

Testing Laravel without mocking everything

I inherited a codebase once with 900 unit tests and no confidence. Every test mocked the repository, mocked the model, mocked the mailer, mocked the thing three layers down. The…

April 11, 2026Read more
Three Eloquent features that clean up your models

Three Eloquent features that clean up your models

The messiest Laravel model I ever wrote wasn't messy because of Eloquent. It was messy because I ignored the parts of Eloquent that exist specifically to keep it clean. The same…

April 4, 2026Read more
The migration that locked the table for eight minutes

The migration that locked the table for eight minutes

The deploy looked boring. One migration, adding a lastseenat column to the users table with a default of now(). I'd written a hundred like it. I ran it at 2pm on a Tuesday becau…

March 28, 2026Read more
Chasing a memory leak in a Laravel queue worker

Chasing a memory leak in a Laravel queue worker

The alert came in at 4am: one of our queue:work processes had been OOM-killed. The supervisor restarted it, it processed jobs for about forty minutes, and got killed again. It w…

March 21, 2026Read more