DEV Community

UCodeSoft
UCodeSoft

Posted on

Caching in Laravel: What Actually Holds Up Once Real Traffic Hits It

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

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. Here's what we've actually learned, the parts that bite.

A few terms first

Cache-aside, the default pattern most Laravel apps should reach for first. Check the cache, on a miss read from the database, and populate the cache. Cache::remember() implements this in one call.

Write-through, writes go to the cache and the database at the same time, synchronously. Reads stay fresh, writes get a little slower.

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 at once.

TTL, how long a cached value lives before it's considered stale. Simple to reason about, but it's a guess about how long data stays close enough to correct.

Tagged invalidation, grouping related cache entries under a shared label so you can clear all of them in one call. Only works on Redis and Memcached, not array or file.

The pattern that should be your default

For most reads, cache-aside via Cache::remember() is the right starting point, not the fanciest option, but the resilient one. Cache unreachable, cold, or empty, the app falls back to the database and keeps working, just 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);
    });
}
Enter fullscreen mode Exit fullscreen mode

That single call does the whole cache-aside dance: check cache, fall through on miss, store the result. Reach for this first on nearly every project, only move past it with a concrete reason.

The stampede problem nobody notices until it's a page at 3 am

A popular key expires. If it backs something with real traffic, dozens or thousands of requests can land on that exact miss window simultaneously. Every one falls through to the database and runs the same query at once, right when the database was least prepared for a burst.

What happens when a hot key expires

The fix is a lock around the cache-population step, so only the first request through the door actually queries the database:

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);
        });
    });
}
Enter fullscreen mode Exit fullscreen mode

Needs a driver with atomic lock support: Redis, Memcached, DynamoDB, file and database won't work here. block() gives waiting requests a few seconds to pick up the freshly-cached value instead of failing outright.

Invalidation is where most caching bugs actually live

The most common caching bug we see: someone updates a record, forgets to invalidate the cached version, the app serves stale data with no error, nothing that looks broken until a user notices.

Two ways a cache gets invalidated

Manual invalidation works, 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;
}
Enter fullscreen mode Exit fullscreen mode

Fine for this one path. Problem is the model probably gets updated from more than one place eventually: an admin panel, a background job, an import script, and everyone needs to remember this line exists. Miss one, quiet bug.

Hook invalidation into model events instead:

class UserObserver
{
    public function updated(User $user): void
    {
        Cache::forget("user:{$user->id}");
    }

    public function deleted(User $user): void
    {
        Cache::forget("user:{$user->id}");
    }
}
Enter fullscreen mode Exit fullscreen mode

Register once, User::observe(UserObserver::class), invalidation stops being something a developer has to remember; it's structurally tied to the model itself, not any one call site.

For one change, clearing several related entries at once, tags handle it without tracking individual keys:

// 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();
Enter fullscreen mode Exit fullscreen mode

Tags only work on Redis and Memcached. On array or file this silently isn't doing what you think.

The pitfall that slips past even careful developers

Caching a query result that itself triggers lazy-loaded relationships doesn't solve your N+1 problem; it relocates it into the cache-population code. The cache entry ends up correct, but building it was just as expensive as never caching; you've only saved the cost on requests after the first.

Eager-load before you cache, always.

The pitfalls that actually show up in production

Takeaways

  • Cache-aside via Cache::remember() is the right default for most reads, resilient to cache failures by design.
  • Stampede protection matters the moment a cached key backs anything with real concurrent traffic; Cache::lock() is a small addition with an outsized payoff.
  • Prefer model observers over manual Cache::forget() calls scattered through the codebase; invalidation should be structurally hard to forget, not a discipline you're hoping every developer maintains.
  • Caching a lazy-loaded relationship just moves your N+1 problem into the cache-population step; eager-load first.
  • Never cache without a TTL, and never cache a failure or empty response; both turn caching from a performance win into an active bug.

Caching isn't a performance hack bolted on at the end; it's a data consistency problem with a performance benefit attached. Treat it that way and most of these pitfalls stop happening.

Full write-up with more context is on our Substack: https://ucodesoft.substack.com/p/caching-in-laravel-what-actually

Top comments (0)