Laravel + Redis: Cache Tags, Locks, and Stampede Prevention
The cache layer choice
| Driver | Speed | Tags support | Best for |
|---|---|---|---|
file | Slow — disk IO | No | Dev only, never prod |
database | Medium | No | Small Laravel apps, no Redis available |
memcached | Fast | Partial | Legacy; not recommended for new apps |
redis | Fast | Yes | Production 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 sessionconfig/database.php — verify Redis client:
'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):
sudo pecl install redis
echo "extension=redis.so" | sudo tee /etc/php.d/50-redis.ini
php -m | grep -i redisStep 2 — Basic caching patterns
Remember (cache-aside):
$products = Cache::remember('products.active', 300, function () {
return Product::active()->with('category')->get();
});Invalidate on write:
Product::create($data);
Cache::forget('products.active');Forever (until manually cleared):
Cache::rememberForever('settings.all', fn() => Setting::all());Step 3 — Cache tags (related-key invalidation)
Redis driver supports tags natively. Tag related entries, invalidate all at once.
// 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 clearedTags 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.
$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:
// 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:
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:
$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:
// 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:
Cache::tags(['page-cache'])->flush();Step 7 — Query caching
$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=defaultBenefits: sticky sessions not needed, session count visible in Redis, easier to wipe all sessions (security incident).
Monitoring cache health
Redis INFO:
redis-cli INFO stats
# keyspace_hits, keyspace_misses → compute hit rateLaravel 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
Valkey is a Redis fork (Linux Foundation). API-compatible; drop-in replacement. Use whichever your OS ships with — AlmaLinux 10+ ships Valkey.
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.
php artisan cache:clear clear tags?
Yes — clears all cache entries. For surgical clear, use Cache::tags(['tag1'])->flush().
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.
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