# 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
| 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 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());
```
## Step 3 — Cache tags (related-key invalidation)
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.