Client Area

Laravel + Redis: Cache Tags, Locks, and Stampede Prevention

ByDomain India Team·DomainIndia Engineering
5 min readPublished 20 Apr 2026Updated 23 Jun 2026252 views

In this article

  • 1The cache layer choice
  • 2Step 1 — Configure Laravel for Redis
  • 3Step 2 — Basic caching patterns
  • 4Step 3 — Cache tags (related-key invalidation)
  • 5Step 4 — Atomic locks (prevent race conditions)

Laravel + Redis: Cache Tags, Locks, and Stampede Prevention

TL;DR
Laravel's cache abstraction over Redis gives you tags (invalidate related keys together), atomic locks (prevent race conditions), and cache-aside patterns. This guide covers the patterns that matter in production: tagged cache, stampede prevention, and fine-grained TTL control on DomainIndia hosting.

The cache layer choice

DriverSpeedTags supportBest for
fileSlow — disk IONoDev only, never prod
databaseMediumNoSmall Laravel apps, no Redis available
memcachedFastPartialLegacy; not recommended for new apps
redisFastYesProduction Laravel

Use Redis. On DomainIndia:

  • Shared cPanel: Redis is available; connection via Unix socket
  • VPS: install locally (sudo dnf install redis) or run as Docker
  • App Platform: add Redis service with one click

Step 1 — Configure Laravel for Redis

.env:

CACHE_DRIVER=redis
SESSION_DRIVER=redis
QUEUE_CONNECTION=redis
REDIS_HOST=127.0.0.1
REDIS_PORT=6379
REDIS_PASSWORD=null
REDIS_DB=0
REDIS_CACHE_DB=1     # separate DB for cache vs session

config/database.php — verify Redis client:

php
'redis' => [
    'client' => env('REDIS_CLIENT', 'phpredis'),
    'options' => [
        'cluster' => env('REDIS_CLUSTER', 'redis'),
        'prefix' => env('REDIS_PREFIX', Str::slug(env('APP_NAME', 'laravel'), '_').'_database_'),
    ],
    'default' => [
        'url' => env('REDIS_URL'),
        'host' => env('REDIS_HOST', '127.0.0.1'),
        'password' => env('REDIS_PASSWORD'),
        'port' => env('REDIS_PORT', 6379),
        'database' => env('REDIS_DB', 0),
    ],
    'cache' => [
        'url' => env('REDIS_URL'),
        'host' => env('REDIS_HOST', '127.0.0.1'),
        'password' => env('REDIS_PASSWORD'),
        'port' => env('REDIS_PORT', 6379),
        'database' => env('REDIS_CACHE_DB', 1),
    ],
],

Install phpredis via PECL (fastest):

bash
sudo pecl install redis
echo "extension=redis.so" | sudo tee /etc/php.d/50-redis.ini
php -m | grep -i redis

Step 2 — Basic caching patterns

Remember (cache-aside):

php
$products = Cache::remember('products.active', 300, function () {
    return Product::active()->with('category')->get();
});

Invalidate on write:

php
Product::create($data);
Cache::forget('products.active');

Forever (until manually cleared):

php
Cache::rememberForever('settings.all', fn() => Setting::all());

Redis driver supports tags natively. Tag related entries, invalidate all at once.

php
// On write:
Cache::tags(['products', 'category:5'])->put("product.$id", $product, 300);

// Elsewhere:
Cache::tags(['category:5'])->put('category.summary.5', $summary, 300);

// When something in category 5 changes:
Cache::tags(['category:5'])->flush();
// → both product and summary entries cleared
Warning

Tags work only with Redis and Memcached, not file/database drivers. Your production and dev environments must match, or you'll hit surprises.

Step 4 — Atomic locks (prevent race conditions)

Two concurrent requests trying to generate the same expensive report. Without a lock, both compute it — wasted work.

php
$value = Cache::remember('expensive.report', 3600, function () {
    return Cache::lock('report.lock', 10)->block(5, function () {
        // Re-check inside lock — another process may have populated while we waited
        return Cache::get('expensive.report') ?? generateExpensiveReport();
    });
});

Or simpler — use Laravel's built-in stampede protection:

php
// Laravel 10+: atomic rememberWithLock
$value = Cache::rememberWithLock('expensive.report', 3600, function () {
    return generateExpensiveReport();
});

Step 5 — Cache stampede prevention

Classic problem: cache expires at 10:00:00, 1,000 concurrent requests stampede the DB.

Fix 1: Atomic lock (above) — only one regenerates; others wait briefly.

Fix 2: Probabilistic early expiration:

php
public static function rememberWithEarlyExpiration(string $key, int $ttl, callable $callback)
{
    $data = Cache::get($key);
    $ttlRemaining = Cache::ttl($key);

    // 10% chance to regenerate when TTL < 20% remaining
    if ($data && $ttlRemaining > ($ttl * 0.2)) {
        return $data;
    }

    if ($data && rand(0, 100) < 90) {
        return $data;  // serve stale, wait a bit
    }

    $fresh = $callback();
    Cache::put($key, $fresh, $ttl);
    return $fresh;
}

Fix 3: Soft TTL + hard TTL:

php
$data = Cache::remember("$key.soft", 300, function () use ($key, $callback) {
    $fresh = $callback();
    Cache::put("$key.hard", $fresh, 600);  // longer backup TTL
    return $fresh;
});

// On cache miss (hard expired):
return Cache::get("$key.hard") ?? $callback();

Step 6 — Full-page cache (middleware)

For guest-accessible pages:

php
// app/Http/Middleware/PageCache.php
public function handle($request, Closure $next)
{
    if ($request->user() || $request->method() !== 'GET') {
        return $next($request);
    }

    $key = 'page.' . sha1($request->fullUrl());
    $cached = Cache::tags(['page-cache'])->get($key);
    if ($cached) {
        return response($cached['body'], $cached['status'])
            ->withHeaders($cached['headers'])
            ->header('X-Cache', 'HIT');
    }

    $response = $next($request);
    if ($response->isSuccessful()) {
        Cache::tags(['page-cache'])->put($key, [
            'body'    => $response->getContent(),
            'status'  => $response->getStatusCode(),
            'headers' => array_filter($response->headers->all(), fn($k) =>
                !in_array($k, ['set-cookie', 'date']), ARRAY_FILTER_USE_KEY),
        ], 300);
    }
    return $response->header('X-Cache', 'MISS');
}

Invalidate all page cache on content edit:

php
Cache::tags(['page-cache'])->flush();

Step 7 — Query caching

php
$users = Cache::remember('users.active', 120, function () {
    return User::where('is_active', true)->get();
});

Or use laravel/scout + rennokki/laravel-eloquent-query-cache for automatic query-level caching.

Session in Redis

Instead of files (default), store sessions in Redis — faster, and works across multiple app servers.

SESSION_DRIVER=redis
SESSION_CONNECTION=default

Benefits: sticky sessions not needed, session count visible in Redis, easier to wipe all sessions (security incident).

Monitoring cache health

Redis INFO:

bash
redis-cli INFO stats
# keyspace_hits, keyspace_misses → compute hit rate

Laravel Horizon (for queue + cache stats) — install via composer require laravel/horizon.

Laravel Telescope (dev-only) — shows cache ops per request.

Target: cache hit rate > 80% for query cache, > 50% for page cache.

Common pitfalls

FAQ

Q Redis or Valkey?

Valkey is a Redis fork (Linux Foundation). API-compatible; drop-in replacement. Use whichever your OS ships with — AlmaLinux 10+ ships Valkey.

Q How much RAM for Redis?

Rule: sum of cached data × 1.3 (overhead). For a typical Laravel app: 512 MB Redis handles ~100K keys. Set maxmemory + maxmemory-policy allkeys-lru to cap.

Q Does php artisan cache:clear clear tags?

Yes — clears all cache entries. For surgical clear, use Cache::tags(['tag1'])->flush().

Q Cache in controller vs model vs query?

Cache at the layer closest to the expensive operation. Repository / query layer is usually right. Controller-level is too coarse (different users see same cache); model observers are too hidden.

Q Can I share cache across Laravel apps?

Yes, but use different REDIS_PREFIX or different REDIS_DB numbers. Or avoid — coupling apps via cache is fragile.

Redis-powered Laravel runs cleanly on a DomainIndia VPS. Explore VPS plans

Was this article helpful?

Your feedback helps us improve our documentation

Still need help? Submit a support ticket