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

3 min read Mohamed Said Mohamed Said

Beyond remember(): Production Caching in Laravel

Most Laravel applications start with Cache::remember() and never look back. That works until you hit real traffic, shared cache keys across tenants, or a deployment that invalidates half your cache simultaneously. This article covers three concrete techniques that separate hobby caching from production caching.


1. Cache Tags for Granular Invalidation

Cache tags let you group related entries and flush them as a unit without touching unrelated keys. They require a tag-aware driver — Redis or Memcached.

// Storing tagged entries
Cache::tags(['products', 'tenant:42'])->put(
    "product:{$product->id}",
    $product,
    now()->addHour()
);

// Retrieve with the same tag set
$product = Cache::tags(['products', 'tenant:42'])
    ->get("product:{$product->id}");

// Flush only tenant 42's product cache on a write
Cache::tags(['tenant:42', 'products'])->flush();

The key insight: tag tenant:{id} on every cache write scoped to that tenant. A single flush() call after a bulk import clears exactly what needs clearing — nothing more.

Gotcha: Cache::tags()->flush() in Redis does not delete keys immediately; it invalidates the tag reference, making tagged keys unreachable. Key memory is reclaimed by Redis's eviction policy. Monitor used_memory if you flush large tag sets frequently.


2. Preventing Cache Stampedes with Atomic Locks

A stampede happens when a hot key expires and dozens of workers simultaneously miss the cache, all hitting the database at once. Laravel's Cache::lock() solves this cleanly.

use Illuminate\Support\Facades\Cache;

function getExpensiveReport(int $id): array
{
    $key = "report:{$id}";
    $cached = Cache::get($key);

    if ($cached !== null) {
        return $cached;
    }

    // Only one worker rebuilds; others wait up to 5 seconds
    return Cache::lock("lock:{$key}", 10)->block(5, function () use ($key, $id) {
        // Re-check after acquiring the lock
        if ($hit = Cache::get($key)) {
            return $hit;
        }

        $data = DB::table('reports')->where('id', $id)->first();
        Cache::put($key, $data, now()->addMinutes(30));

        return $data;
    });
}

The double-check inside the lock is mandatory. Without it, every worker that was queued behind the lock rebuilds the cache anyway.

block(5) throws LockTimeoutException if the lock is not acquired within 5 seconds — catch it and fall back to a direct DB read rather than letting the request fail.


3. The Cache-Aside Pattern in a Service Class

Inlining cache logic in controllers couples your caching strategy to your HTTP layer. Extract it into a dedicated repository or service:

final class CachedProductRepository
{
    public function __construct(
        private readonly ProductRepository $inner,
        private readonly Repository $cache,
    ) {}

    public function findById(int $id): ?Product
    {
        return $this->cache
            ->tags(['products'])
            ->remember(
                "product:{$id}",
                now()->addMinutes(60),
                fn () => $this->inner->findById($id)
            );
    }

    public function invalidate(int $id): void
    {
        $this->cache->tags(['products'])->forget("product:{$id}");
    }
}

Bind it in a service provider:

$this->app->bind(ProductRepository::class, function ($app) {
    return new CachedProductRepository(
        inner: $app->make(EloquentProductRepository::class),
        cache: $app->make('cache.store'),
    );
});

Now your controllers depend on ProductRepository — the caching layer is invisible to them and trivially swappable in tests.


Key Takeaways

  • Cache tags enable surgical invalidation; always tag by tenant and entity type in multi-tenant apps.
  • Atomic locks with block() prevent stampedes; always re-check the cache after acquiring the lock.
  • Cache-aside in a dedicated class keeps controllers clean and makes the caching strategy testable in isolation.
  • Catch LockTimeoutException and degrade gracefully — never let a lock failure become a 500.
  • Prefer now()->addMinutes() over integer TTLs; it reads clearly and respects Carbon timezone config.

Found this useful?

Frequently Asked Questions

3 questions
Q01 Which Laravel cache drivers support cache tags?
Only Redis and Memcached support cache tags. The file, database, and array drivers do not. If you call Cache::tags() on an unsupported driver, Laravel throws a BadMethodCallException at runtime.
Q02 What happens if the lock owner crashes before releasing the lock?
Laravel's cache locks accept a TTL (the first argument to Cache::lock()). If the owning process dies, the lock expires automatically after that many seconds, allowing another worker to acquire it. Always set a realistic TTL — slightly longer than your worst-case rebuild time.
Q03 Should I cache Eloquent models or plain arrays?
Prefer plain arrays or simple DTOs. Caching Eloquent models serializes relationship state, which can be stale or unexpectedly large. Reconstructing a model from a cached array via Model::make() or a DTO factory is safer and produces smaller cache payloads.

Continue reading

More Articles

View all