Redis Patterns
Constraints
- Apply
@rules/laravel/laravel.mdc— use the framework's facades (Cache,RateLimiter,Redis), not a raw client. - Apply
@rules/laravel/queue-debouncing.mdcfor queue/job coalescing concerns when Redis backs the queue. - Cross-link
@rules/sql/optimalize.mdc(DB-level caching) — Redis caching sits in front of the query tuning that rule owns; cache the result, do not paper over an unindexed query. finalclasses,declare(strict_types=1), Pest tests (use thearraycache driver in tests unless asserting Redis-specific behavior).- Always set a TTL. Keys without expiry accumulate and cause memory pressure.
Use when
- Adding caching, rate limiting, distributed coordination, or pub/sub to a Laravel app.
- Choosing a cache strategy, protecting a cold cache from stampede, or designing key/TTL conventions.
- Configuring Redis as the session, cache, or queue store.
Use Laravel facades throughout. Reach for raw Redis::command(...) only for structures the Cache abstraction does not expose (sorted sets, streams).
Caching Strategies
Cache-Aside (default for read-heavy data)
$product = Cache::remember("product:{$id}", now()->addMinutes(10), fn () =>
Product::findOrFail($id),
);
remember() is read-through cache-aside: returns the cached value or runs the closure, stores it, and returns it. Use rememberForever() only with an explicit invalidation path.
Write-Through (consistency required)
$product->update($data);
Cache::put("product:{$product->id}", $product->fresh(), now()->addMinutes(10));
// Or simply invalidate so the next read repopulates:
Cache::forget("product:{$product->id}");
Invalidate (forget) rather than rewrite when the cached shape may differ from the model.
Cache Tags (grouped invalidation)
Cache::tags(['products', "category:{$categoryId}"])
->remember("product:{$id}", now()->addMinutes(10), fn () => Product::findOrFail($id));
Cache::tags(["category:{$categoryId}"])->flush(); // drop the whole group at once
Tags require the redis (or memcached) store — not file/database. They add key overhead; use for genuine groups, not single keys.
Stampede Protection
A cold/expired hot key can trigger many concurrent rebuilds (thundering herd). Guard the rebuild with an atomic lock so only one worker recomputes.
public function getReport(string $key): array
{
return Cache::get($key) ?? Cache::lock("rebuild:{$key}", seconds: 10)->block(5, function () use ($key) {
// Re-check inside the lock — another worker may have just populated it.
return Cache::get($key) ?? tap($this->computeReport(), fn ($v) =>
Cache::put($key, $v, now()->addMinutes(15)),
);
});
}
Cache::lock()is an atomicSET NX PXlock;block(5, ...)waits up to 5s to acquire.- Alternative for very hot keys: stale-while-revalidate — serve the slightly stale value while one worker refreshes in the background, avoiding any blocking.
Distributed Locks
Coordinate exclusive access across workers/requests.
$lock = Cache::lock('payment:'.$orderId, seconds: 30);
if ($lock->get()) {
try {
$this->processPayment($orderId);
} finally {
$lock->release(); // always release in finally
}
}
- The TTL must exceed the expected work; on crash the lock auto-expires so it never wedges permanently.
- Only the owner may release — Laravel tracks the owner token, so
release()is safe. UseforceRelease()only deliberately. - For bounded concurrency (N parallel, not 1), use
Redis::funnel('job')->limit(3)->then(fn () => ...).
Rate Limiting
Prefer the framework limiter over hand-rolled counters.
use Illuminate\Support\Facades\RateLimiter;
$executed = RateLimiter::attempt("send:{$user->id}", maxAttempts: 5, function (): void {
$this->sendMessage();
}, decaySeconds: 60);
if (! $executed) {
abort(429); // or RateLimiter::availableIn($key) for retry hint
}
- HTTP routes: define a named limiter (
RateLimiter::for('api', fn ($r) => Limit::perMinute(60)->by($r->user()?->id ?: $r->ip()))) and applythrottle:apimiddleware. - For high-throughput throttling,
Redis::throttle('key')->allow(60)->every(60)->then($ok, $tooMany)runs the check in a single atomic Redis call.
Key Naming & TTL Discipline
{app}:{resource}:{id} myapp:product:123
{app}:{resource}:{id}:{field} myapp:order:456:status
{app}:{resource}:{date} myapp:stats:pageviews:2026-06-14
| Data | Suggested TTL |
|---|---|
| API/query response cache | 5–15 min |
| User session | 24h |
| Rate-limit window | = window size |
| Short-lived token | 5–10 min |
| Reference/static data | 1h–1 week |
- Use Laravel's
cache.prefixfor the app namespace; do not hand-prefix every key. - Never run
KEYS *in production (O(N), blocks the server) — useSCANviaRedis::scan().
Eviction Policy
Set maxmemory + maxmemory-policy in redis.conf per role:
| Policy | Use for |
|---|---|
allkeys-lru |
General cache (evict least-recently-used) |
volatile-lru |
Mixed cache + must-keep data (only evict keys with TTL) |
allkeys-lfu |
Skewed access (hot keys survive) |
noeviction |
Queue / session store — errors instead of dropping data |
Critical: do not point a Redis queue/session connection at an allkeys-lru instance — it will silently evict jobs/sessions. Separate the cache instance/DB from the queue instance.
Pub/Sub
Fire-and-forget broadcast; no delivery guarantee or replay.
Redis::publish('orders', json_encode(['id' => $order->id]));
// Long-running listener (artisan command):
Redis::subscribe(['orders'], function (string $message): void {
$this->handle(json_decode($message, true));
});
If you need durability, consumer groups, or replay, use the Laravel queue (below) or Redis Streams via Redis::command('XADD', ...) — Pub/Sub drops messages for absent subscribers.
Pipelines & Transactions
Batch many commands in one round trip; use a transaction when they must apply atomically.
Redis::pipeline(function ($pipe) use ($ids): void {
foreach ($ids as $id) {
$pipe->del("product:{$id}");
}
}); // one round trip, NOT atomic
Redis::transaction(function ($tx) use ($key): void {
$tx->incr($key);
$tx->expire($key, 60);
}); // MULTI/EXEC — all-or-nothing
Pipelines cut latency for bulk ops; transactions add atomicity. Do not loop single commands when a pipeline fits.
Queues, Sessions & Stores
- Queues: set
QUEUE_CONNECTION=redis. Run Laravel Horizon for Redis queues — it gives supervisor config, metrics, and a dashboard. For coalescing bursty jobs see@rules/laravel/queue-debouncing.mdc. - Sessions / cache:
SESSION_DRIVER=redis,CACHE_STORE=redis. Keep sessions/queue on anoevictioninstance and cache on anallkeys-lruinstance (or distinct DB indexes) so cache eviction never drops a session or job. - Size the predis/phpredis pool and set
socket_timeout/read_timeoutso a stalled Redis fails fast instead of hanging requests.
Done when
- Every key has an intentional TTL and follows the naming convention.
- Hot/cold cache rebuilds are stampede-protected with
Cache::lock. - Cache and queue/session use separate instances or DBs with matching eviction policies.
- Rate limiting uses
RateLimiter/Redis::throttle, not ad-hoc counters. - Pest tests cover cache hit/miss and lock paths; no
KEYS *in production code.