Laravel model caching with Redis: the stale-read bugs that bite under load
Model caching with Redis looks simple until concurrent writes show up. Here's the race condition I keep seeing and how to actually fix it.
Model caching with Redis is one of those things that works perfectly in development and then quietly serves wrong data in production. The bug doesn't crash anything. There's no exception in the logs. A user just sees a price, a status, or an inventory count that's a few seconds out of date - at exactly the wrong moment.
I've shipped this bug myself. I've also inherited it from other developers. It always looks the same.
What model caching is supposed to solve
The pattern is straightforward: fetching a row from Postgres on every request adds latency and database load. For data that changes infrequently - a product record, a user profile, a config value - you cache the model in Redis and serve it from there. Miss rate is low, the database stays calm, response times drop.
The implementation everyone reaches for looks like this:
// The naive version - looks fine, isn't fine under concurrent writes
public static function findCached(int $id): ?Product
{
$key = "product:{$id}";
return Cache::remember($key, now()->addMinutes(10), function () use ($id) {
return Product::find($id);
});
}
And when something updates, you flush the key:
public static function boot(): void
{
parent::boot();
static::saved(function (Product $product) {
Cache::forget("product:{$product->id}");
});
}
This works fine at low traffic with infrequent writes. Under concurrent writes - think a webhook receiver, a queue with multiple workers, or a busy checkout flow - it falls apart.
The race condition, spelled out
Here's what happens when two processes touch the same record at roughly the same time:
- Worker A writes an update to the database, then calls
Cache::forget("product:42"). - Worker B is a millisecond behind. It wrote its own update, then calls
Cache::forget("product:42")too. Both forgets succeed. The key is gone. - The next request comes in and calls
findCached(42). Cache miss, so it firesProduct::find(42). It gets the current row and stores it in Redis.
So far so good. But here's the version that actually burns you:
- Worker A writes an update and fires
Cache::forget("product:42"). - A request comes in immediately after the forget - cache miss. It queries the database and gets Worker A's version. It writes that to Redis with a 10-minute TTL.
- Worker B, which started slightly after Worker A, finishes its write to the database. It calls
Cache::forget("product:42")- but the key already has Worker A's version in it. The forget fires. The key is gone. - The next request comes in, cache miss, queries the database, gets Worker B's version. Writes it to Redis.
This version also looks fine. The real problem is the interleaving where a read sneaks in between the write and the forget:
- Worker A writes to the database.
- A request reads from the cache (old data, TTL still valid). Serves stale.
- Worker A fires
Cache::forget. - Worker B writes to the database.
- A request triggers a cache fill - gets Worker B's version.
- Worker B fires
Cache::forget. - Another request triggers a fill - gets the current version.
That step 2 read is the one that matters. During a busy period with multiple writers, the window where a fresh write exists in Postgres but old data lives in Redis can stay open longer than your TTL intuition suggests.
I hit this hard on an e-commerce project last year - a client with flash sales. Inventory counts were cached on the product model. Under concurrent checkout traffic, we were occasionally allowing purchases against stale stock counts. Not often. Maybe 1 in 400 requests during the spike. Enough to oversell.
The fix: cache versioning with an atomic write
The pattern I now use is cache versioning. Instead of storing the model directly under a stable key, you store a version counter separately and fold it into the cache key. When you want to invalidate, you just increment the version. Old cached values become unreachable without you needing to delete them.
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Redis;
class Product extends Model
{
private static function versionKey(int $id): string
{
return "product_version:{$id}";
}
private static function currentVersion(int $id): int
{
// INCR initializes to 1 if the key doesn't exist - atomic in Redis
return (int) Redis::get(static::versionKey($id)) ?: 1;
}
public static function findCached(int $id): ?self
{
$version = static::currentVersion($id);
$dataKey = "product:{$id}:v{$version}";
return Cache::remember($dataKey, now()->addMinutes(10), function () use ($id) {
return static::find($id);
});
}
public static function invalidateCache(int $id): void
{
// Atomic increment - this is the only write to the version key
Redis::incr(static::versionKey($id));
}
protected static function boot(): void
{
parent::boot();
static::saved(function (Product $product) {
static::invalidateCache($product->id);
});
static::deleted(function (Product $product) {
static::invalidateCache($product->id);
});
}
}
The key insight is Redis::incr(). That's an atomic operation. No two workers can race on it and produce the same version number. One of them gets version 3, the other gets version 4. Any reader that fetched version 2 data keeps serving version 2 data until its TTL expires - but 10 minutes of stale data is an acceptable tradeoff and you can tune that TTL down to 60 seconds without sweating it.
The version 2 key just sits in Redis unused until Redis evicts it. That's fine. Add a reasonable TTL to the version key itself if you want to keep memory tidy:
public static function invalidateCache(int $id): void
{
$key = static::versionKey($id);
Redis::incr($key);
// Keep the version key alive for 24 hours
Redis::expire($key, 86400);
}
Other gotchas I've hit
Serialization of Eloquent models. Laravel serializes models to the cache using PHP serialization. If your model has a relationship loaded on it, that relationship gets cached too. Six months later you add a new column to a related table and the cached shape doesn't match. I tag anything going into the cache with ->only([...]) or store just the attributes array and reconstruct:
Cache::remember($dataKey, now()->addMinutes(10), function () use ($id) {
$product = static::find($id);
return $product ? $product->attributesToArray() : null;
});
Then in findCached you hydrate it back:
$attrs = Cache::remember(...);
return $attrs ? (new static)->forceFill($attrs) : null;
Verbose, but it stops you from caching a surprise BillingAddress object alongside your product.
The thundering herd on cold cache. If you flush a key that's read 500 times per second, every request during the ~5ms window before the first reader re-populates the cache hits the database simultaneously. On small tables with fast queries this is fine. On anything heavier, use a lock:
return Cache::remember($dataKey, now()->addMinutes(10), function () use ($id) {
return Cache::lock("product_fill:{$id}", 5)->block(2, function () use ($id) {
return static::find($id);
});
});
The second request through the lock just re-fills from the database, which at this point already has the lock-holder's result and will get cached on the next miss. Good enough.
Queue jobs bypassing the model observer. If you do a bulk update with Product::whereIn('id', $ids)->update([...]), Eloquent never fires the saved observer. The cache never gets invalidated. I've burned myself on this in queue jobs that do mass price updates. Either dispatch individual saves, or explicitly call Product::invalidateCache($id) in your job loop.
When I'd reach for this pattern (and when I wouldn't)
I use versioned Redis model caching when:
- The model is read far more often than it's written (10:1 or higher).
- The data is expensive to fetch - joined queries, external API calls baked into the model, etc.
- I have multiple queue workers or webhook receivers writing to the same records concurrently.
I skip it when:
- The model changes on almost every request. Caching a shopping cart makes no sense.
- The table is small and Postgres is fast. Not every query needs Redis in front of it.
- The business logic cannot tolerate any staleness. Financial ledger entries, audit logs - those should not be cached at the model layer at all.
For healthcare and biotech clients especially, I'm conservative. If a cache miss could serve a patient's old medication dosage, the cache doesn't exist. Period.
Closing thought
The naive Cache::remember / Cache::forget pattern is fine as a starting point, but it has a real race condition that traffic exposes. Cache versioning with Redis::incr() closes that window atomically and costs almost nothing extra. Ship the simple version, watch your metrics, and refactor to versioning the moment you see concurrent writers on the same records.
Need help shipping something like this? Get in touch.