Laravel caching is the cheapest way to remove load from PostgreSQL, but most teams only ever call Cache::remember() and hope. This guide covers the driver choice, key design, tag-based invalidation, atomic locks, and stampede protection that hold up under concurrent traffic. In production systems I've tuned, the cache layer — not the query — decided whether a page stayed under 200 ms.
The short version
Use a shared Laravel redis cache for both values and locks, namespace every key by tenant and version, invalidate with tags instead of guessing key names, and wrap expensive reads in an atomic lock so a cold key doesn't trigger a thousand identical queries.
Which cache driver should Laravel caching use in production?
The file driver is fine for local development and terrible for anything with more than one app server. Each node gets its own copy, so invalidation only clears the node that handled the write. The database driver centralizes state but puts cache reads back on the same database you were trying to protect.
A Laravel redis cache wins for most deployments because it gives you a shared store, TTL at the key level, tag support, and the atomic locks that Cache::lock() depends on. Configure it once in config/database.php and point the cache store at it:
// config/cache.php
'default' => env('CACHE_STORE', 'redis'),
'stores' => [
'redis' => [
'driver' => 'redis',
'connection' => 'cache',
'lock_connection' => 'default',
],
],
Keep the cache connection separate from the queue connection. A cache flush should never evict queued jobs, and a queue backlog should never push cached pages out of memory. Two logical Redis databases, two maxmemory-policy settings: allkeys-lru for cache, noeviction for queues.
Driver | Shared across nodes | Tags | Atomic locks | Best fit |
|---|---|---|---|---|
file | No | No | No | Local dev only |
database | Yes | No | No | Single small app |
memcached | Yes | No | No | Pure key/value reads |
redis | Yes | Yes | Yes | Most Laravel apps |
How do you design cache keys that survive refactors?
A cache key is a public interface. If it is assembled inline at ten call sites, changing the shape of a returned model breaks nine of them silently. Centralize key generation in a small class or a method on the model itself.
final class CacheKeys
{
public static function tenant(int $tenantId): string
{
return "t{$tenantId}";
}
public static function dashboard(int $tenantId, int $userId): string
{
return self::tenant($tenantId) . ":dashboard:u{$userId}";
}
}
Prefix every key with the tenant identifier so a single tenant's data can be purged without touching anyone else. Add a version segment when the payload shape changes — v2 in the key is cheaper than a coordinated flush across every node. Finally, keep keys short: Redis stores the full key string per entry, and long keys add up across millions of entries.
Never interpolate raw user input into a cache key. Hash it first with hash('xxh128', $input) so a crafted string can't collide with another tenant's key or blow past Redis key-length limits.
Laravel cache tags: invalidation without key archaeology
TTL-based expiry alone forces you to choose between stale data and short hit windows. Laravel cache tags let you group related entries and flush them as a unit when the underlying rows change.
use Illuminate\Support\Facades\Cache;
$posts = Cache::tags(['tenant:'.$tenantId, 'posts'])
->remember("posts:list:{$tenantId}", now()->addMinutes(10), function () use ($tenantId) {
return Post::where('tenant_id', $tenantId)
->latest('published_at')
->limit(50)
->get();
});
// On write, drop only what changed.
Cache::tags(['tenant:'.$tenantId, 'posts'])->flush();
Tags work with Redis and Memcached, not with file or database. That is another reason the driver choice comes first. Keep the tag vocabulary small and stable — tenant:{id} plus an entity name covers most apps — because every tag you add is another index Redis has to maintain on write.
How do you stop cache stampede in Laravel?
The failure mode that bites hardest: a popular key expires, hundreds of concurrent requests miss simultaneously, and every one of them runs the same expensive query. The database sees a spike exactly when it is least able to absorb it. This is the classic cache stampede Laravel applications hit on dashboards and report pages.
Two defenses. First, stagger expiry with a random jitter so keys created together do not die together. Second, wrap the recomputation in an atomic lock so only one worker rebuilds the value while the rest wait briefly and read the fresh result.
use Illuminate\Support\Facades\Cache;
function cachedReport(int $tenantId): array
{
$key = "tenant:{$tenantId}:report:v3";
if ($cached = Cache::get($key)) {
return $cached;
}
$lock = Cache::lock("{$key}:lock", 10);
try {
$lock->block(5);
return Cache::remember($key, now()->addMinutes(15)->addSeconds(random_int(0, 60)), function () use ($tenantId) {
return buildExpensiveReport($tenantId);
});
} catch (\Illuminate\Contracts\Lock\LockTimeoutException $e) {
return Cache::get($key, fn () => buildExpensiveReport($tenantId));
} finally {
optional($lock)->release();
}
}
The block(5) call makes waiting workers sleep rather than hammer the database. The fallback in the catch block keeps the endpoint responsive if the lock holder dies mid-rebuild. Ten seconds of lock TTL is enough for most report queries; raise it only when you can prove the query needs it.
What should never go in the cache?
Cache the result of a read, not the state of a write. Anything that must be transactionally consistent — account balances, inventory counts, permission checks that gate a mutation — belongs in PostgreSQL with the right indexes, not in Redis with a TTL.
Also avoid caching entire authenticated page payloads keyed only by URL. Session-scoped data leaks across users the moment the key omits the user or tenant. If you must cache fragments, include the authorization scope in the key and test the miss path in CI.
Data | Cache it? | Why |
|---|---|---|
Public listing pages | Yes, with tags | Read-heavy, tolerant of seconds of staleness |
Aggregate dashboards | Yes, with locks | Expensive query, many concurrent readers |
Account balances | No | Correctness beats latency |
Permission checks | Short TTL only | Revocation must propagate quickly |
Instrument hit rate before tuning TTLs. A low hit rate usually means keys are too specific, not that the TTL is too short. Log Cache::get() misses in staging and count them per key prefix.
Monitoring and eviction in production
Set maxmemory on the Redis instance and choose the policy deliberately. Under allkeys-lru, Redis evicts the least recently used key when memory fills — fine for a pure cache, catastrophic if queue jobs or sessions share the same instance. Watch evicted_keys and keyspace_hits in your metrics; a rising eviction count with a flat hit rate means the working set no longer fits.
For queue-heavy apps, keep the existing Laravel queue best practices in mind and isolate the cache connection, as shown earlier. If your cache misses are actually query problems, the fixes in Eloquent N+1 in Laravel apply before any caching layer. Background reading on key eviction behaviour lives in the Redis eviction documentation, and the full API surface is in the Laravel cache documentation.
Is Cache::remember always the right call?
No. Laravel cache remember is the default for a reason — it reads, computes on miss, and writes in one call — but it hides the miss path. When a key is expensive and popular, that hidden miss is exactly where stampede protection belongs, so reach for an explicit lock instead.
Use remember for cheap, low-contention reads: config lookups, small reference tables, single-row fetches. Use the lock pattern for anything that scans, aggregates, or fans out to another service. Mixing both in one app is normal; the mistake is using remember everywhere and discovering the miss cost during a traffic spike.
FAQ
Does Laravel caching work with multiple app servers?
Only with a shared driver. Redis and Memcached work across nodes; the file driver keeps a separate cache per server, so invalidation on one node leaves the others stale.
Are Laravel cache tags supported on every driver?
No. Tags require Redis or Memcached. The file and database drivers throw a BadMethodCallException when you call tags(), so pick the driver before designing invalidation.
How do I prevent cache stampede in Laravel?
Combine random TTL jitter with Cache::lock() around the recomputation. One worker rebuilds the value while others block briefly, then read the fresh entry.
What TTL should I use for cached queries?
Start at five to fifteen minutes for read-heavy listings, then tune from measured hit rate and staleness tolerance. There is no universal number; the key design matters more than the TTL.
Treat the cache as a system with its own failure modes, not a decorator you sprinkle on queries. Choose a Laravel redis cache, namespace keys by tenant, invalidate with Laravel cache tags, and guard cold keys with locks. For a broader set of conventions, see the other Laravel guides on the blog or the projects listed at /#projects.
