Laravel Caching Strategies: Tags, Stampede Prevention, and Cache-Aside at Scale
#laravel #caching #performance #redis

Laravel Caching Strategies: Tags, Stampede Prevention, and Cache-Aside at Scale

4 min read Mohamed Said Mohamed Said

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 database and file drivers do not support tags. If you switch drivers in tests, tag calls silently no-op. Pin your test environment to redis or use array with 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.

Found this useful?

Frequently Asked Questions

3 questions
Q01 Does Cache::tags() work with the file or database driver in Laravel?
No. Tag support is only available on the Redis and Memcached drivers. Using tags with the file or database driver will throw a BadMethodCallException. In test environments, use the Redis driver or swap to a tag-aware in-memory store.
Q02 What is the difference between a cache stampede lock and a regular mutex?
Cache::lock() in Laravel is backed by an atomic Redis SET NX operation, making it safe across multiple workers and servers. A regular PHP mutex only protects a single process. The block() method lets waiting workers re-use the freshly populated cache value instead of each rebuilding it independently.
Q03 When should I write to the cache inside a save method vs. just invalidating?
Prefer invalidation (forget) over writing inside save. Writing risks a race where a stale value overwrites a fresher one set by a concurrent reader. Only write eagerly if cache misses are extremely expensive and you can guarantee ordering — which is rarely possible without additional coordination.

Continue reading

More Articles

View all