Client Area

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

ByDomain India Team·DomainIndia Engineering
5 min read24 Apr 20263 views
# 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()); ``` ## 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.

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