Why Basic Caching Breaks Under Load
Most Laravel developers start with Cache::remember() and call it done. That works fine at low traffic, but three failure modes emerge at scale: tag-based invalidation becomes impossible, cache stampedes hammer the database when a popular key expires, and stale reads sneak in when you skip the cache-aside pattern. Let's fix all three.
Tagged Caches for Precise Invalidation
Redis and Memcached drivers support cache tags, letting you group related keys and flush them atomically without knowing every individual key.
// Storing with tags
Cache::tags(['products', 'tenant:42'])->put(
"product:{$product->id}",
$product->toArray(),
now()->addHour()
);
// Flush everything for tenant 42 on plan change
Cache::tags('tenant:42')->flush();
// Flush only product listings, not user data
Cache::tags('products')->flush();
Warning: The
databaseandfiledrivers do not support tags. If you switch drivers in tests, tag calls silently no-op. Pin your test environment toredisor usearraywith a tag-aware wrapper.
Scoping Tags to Tenants
In a multi-tenant app, prefix every tag with the tenant identifier so a flush never bleeds across boundaries:
final class TenantCache
{
public function __construct(private readonly int $tenantId) {}
public function tags(string ...$groups): Repository
{
$scoped = array_map(
fn(string $g) => "tenant:{$this->tenantId}:{$g}",
$groups
);
return Cache::tags($scoped);
}
}
Preventing Cache Stampedes with Atomic Locks
When a high-traffic key expires, dozens of workers race to rebuild it simultaneously. The result: a thundering herd that overwhelms your database.
Laravel's Cache::lock() gives you an atomic mutex:
function getPopularFeed(int $userId): array
{
$key = "feed:{$userId}";
if ($cached = Cache::get($key)) {
return $cached;
}
// Only one worker rebuilds; others wait up to 5 s then re-read
return Cache::lock("lock:{$key}", seconds: 10)
->block(5, function () use ($key, $userId): array {
// Double-check after acquiring the lock
if ($cached = Cache::get($key)) {
return $cached;
}
$data = FeedBuilder::for($userId)->build();
Cache::put($key, $data, now()->addMinutes(5));
return $data;
});
}
The double-check inside the lock body is critical — without it, every queued worker rebuilds the cache after the first one finishes.
The Cache-Aside Pattern with Eloquent
Cache-aside means your application — not the cache — is responsible for loading on miss and writing on update. Laravel's remember() handles reads, but writes need explicit invalidation:
final class ProductRepository
{
private const TTL = 3600;
public function find(int $id): Product
{
return Cache::tags('products')->remember(
"product:{$id}",
self::TTL,
fn() => Product::with('variants')->findOrFail($id)
);
}
public function save(Product $product): void
{
$product->save();
// Invalidate immediately; next read repopulates
Cache::tags('products')->forget("product:{$product->id}");
}
}
Avoid writing to the cache inside save() — you risk a race condition where the stale write lands after a concurrent reader has already stored fresh data.
Probabilistic Early Expiry (XFetch)
For keys that are expensive to rebuild, you can proactively refresh before expiry using the XFetch algorithm — recompute with a probability that increases as the key ages:
function xfetch(string $key, int $ttl, Closure $compute, float $beta = 1.0): mixed
{
[$value, $expiry, $delta] = Cache::get($key) ?? [null, 0, 0];
$now = microtime(true);
if ($value === null || $now - $delta * $beta * log(random_int(1, PHP_INT_MAX) / PHP_INT_MAX) >= $expiry) {
$start = microtime(true);
$value = $compute();
$delta = microtime(true) - $start;
$expiry = $now + $ttl;
Cache::put($key, [$value, $expiry, $delta], $ttl + 60);
}
return $value;
}
This is a niche tool — reach for it only when a stampede lock's blocking latency is unacceptable.
Takeaways
- Use cache tags to group related keys and flush them by domain concept, not by guessing key names.
- Always scope tags to tenants in multi-tenant apps to prevent cross-tenant invalidation.
- Implement the double-checked lock pattern with
Cache::lock()->block()to eliminate stampedes. - Prefer cache-aside with explicit invalidation over writing to the cache inside save operations.
- XFetch is a last resort for extremely expensive computations where blocking is not acceptable.