Caching in Laravel, what actually holds up once real traffic hits it
"Redis solves the easy 80%. The other 20% is where production incidents live."
“Just add Redis” solves the easy part of caching, and it solves it fast. Wrap a query in Cache::remember(), watch response times drop, ship it. The problem is that’s maybe 80% of the work, and the remaining 20%, stale data, stampedes, invalidation bugs that only show up under real concurrency, ends up eating more engineering time than the original slow query ever cost anyone.
We’ve shipped caching layers that held up fine in staging and fell over in production more than once, and every time, the cause was one of a small handful of patterns repeating itself. This is what we’ve actually learned reaching for caching on real projects, not the theory, the parts that bite.
A few terms first, in case you need them
Skip ahead if cache-aside and write-through are already familiar.
Cache-aside, the default pattern, and the one most Laravel apps should reach for first. The app checks the cache, and on a miss, reads from the database and populates the cache for next time. Laravel’s Cache::remember() implements this for you in one call.
Write-through: writes go to the cache and the database at the same time, synchronously. Reads stay fresh, but every write gets a little slower since it’s doing two things instead of one.
Cache stampede: what happens when a popular key expires, and a large number of requests hit the miss at the same moment. Without protection, all of them fall through to the database simultaneously, running the same expensive query over and over in parallel.
TTL (time to live): how long a cached value is allowed to live before it’s considered stale. Simple to reason about, but it’s a guess; you’re deciding upfront how long data stays “close enough” to correct.
Tagged invalidation, a way to group related cache entries under a shared label so you can clear all of them in one call, instead of tracking every individual key by hand. Only works on drivers that support it: Redis and Memcached, not the array or file drivers.
The pattern that should be your default
For most reads in a Laravel app, cache-aside via Cache::remember() is genuinely the right starting point, not because it’s the fanciest option but because it’s resilient. If the cache is unreachable, cold, or just empty, the app falls back to the database and keeps working, just a bit slower.
use Illuminate\Support\Facades\Cache;
function getUser(int $userId): ?User
{
return Cache::remember("user:{$userId}", now()->addMinutes(5), function () use ($userId) {
return User::find($userId);
});
}
That single call is doing the whole cache-aside dance for you: check the cache, fall through to the closure on a miss, store whatever comes back. It’s the pattern we reach for first on nearly every project, and we only move past it when there’s a concrete reason to.
The stampede problem nobody notices until it’s a 3 am page
Here’s the failure mode that catches people off guard. A popular key expires, and if that key backs something with real traffic, dozens or hundreds or thousands of requests can land on that exact miss window at the same time. Every single one of them falls through to the database and runs the same query, all at once, right when the database was least prepared for a sudden burst.
The fix is a lock around the cache-population step, so only the first request through the door actually queries the database, and everyone else waits a moment and picks up the value that request just wrote.
use Illuminate\Support\Facades\Cache;
function getUserSafe(int $userId): ?User
{
$key = "user:{$userId}";
if ($cached = Cache::get($key)) {
return $cached;
}
return Cache::lock("lock:{$key}", 5)->block(3, function () use ($key, $userId) {
// Re-check inside the lock, someone else may have populated it already
return Cache::remember($key, now()->addMinutes(5), function () use ($userId) {
return User::find($userId);
});
});
}
This needs a driver that actually supports atomic locks: Redis, Memcached, DynamoDB, file and database drivers won’t cut it here. block() gives waiting requests up to a few seconds to pick up the freshly-cached value instead of failing outright, which is what actually smooths out that burst instead of just moving the problem somewhere else.
Invalidation is where most caching bugs actually live
If there’s one caching bug we see more than any other, it’s this: someone updates a record, forgets to invalidate the cached version, and now the app is serving stale data with no error, no warning, nothing that looks broken until a user notices something’s wrong that shouldn’t be.
The manual approach works, right up until it doesn’t:
function updateUser(int $userId, array $data): User
{
$user = User::findOrFail($userId);
$user->update($data);
Cache::put("user:{$userId}", $user, now()->addMinutes(5));
return $user;
}
That’s fine for this one code path. The problem is the same model probably gets updated from more than one place eventually: an admin panel, a background job, an import script, and every single one of those needs to remember this line exists. Miss one, and you’ve got a quiet, hard-to-reproduce bug.
We prefer hooking invalidation into model events instead, so it happens automatically no matter where the update comes from:
class UserObserver
{
public function updated(User $user): void
{
Cache::forget("user:{$user->id}");
}
public function deleted(User $user): void
{
Cache::forget("user:{$user->id}");
}
}
Registered once, User::observe(UserObserver::class), and now invalidation isn’t something a developer has to remember. It’s structurally impossible to forget, because it’s not tied to any one place the update happens to be called from.
For cases where one change should clear several related entries at once, a user’s profile cache, their permissions cache, their dashboard summary, tags handle that without you tracking every individual key:
// Writing with tags
Cache::tags(['users', "user:{$userId}"])->put("user:{$userId}:profile", $profile, 300);
// Invalidate everything tagged 'users' in one call, e.g. after a bulk import
Cache::tags(['users'])->flush();
Worth knowing upfront, tags only work on Redis and Memcached. If you’re on array or file in any environment, this silently isn’t doing what you think it’s doing.
The pitfall that’s easy to miss even when you’re being careful
This one’s subtle enough that we’ve seen it slip past experienced developers: caching a query result that itself triggers lazy-loaded relationships doesn’t actually solve your N+1 problem; it just relocates it into the code that populates the cache. The cache entry ends up correct, but building it the first time was just as expensive as never caching at all, you’ve only saved the cost on every request after the first.
Eager-load before you cache, always, and the problem disappears entirely.
A few other patterns worth watching for specifically: caching a failed API call or an empty query result serves that failure or emptiness to every user for the entire TTL window, which is worse than not caching at all. The array driver resets on every request by design, perfect for tests, silently disastrous if it ends up active in a real environment by config mistake; you’ll see a 0% hit rate and have no idea why. And skipping the TTL argument entirely means a key lives forever until something manually evicts it, which is a fine way to slowly run a Redis instance out of memory without noticing until it’s a production incident.
Where we landed
Caching isn’t really a performance hack you bolt on once everything else is built; it’s a data consistency problem that happens to come with a performance benefit attached. Treated as a performance trick, it’s where all of the bugs above come from. Treated as a consistency problem first, cache-aside as the default, locks around anything genuinely hot, invalidation wired into model events instead of scattered manually across the codebase, most of what we’ve listed here simply stops happening.
The database should always be the source of truth, and a flushed Redis instance should mean your app gets slower for a moment, not that it starts serving wrong answers. Design for that from the start, and caching earns its keep instead of becoming its own category of incident.




