Beyond remember(): Production Caching in Laravel
Most Laravel applications start with Cache::remember() and never look back. That works until you hit real traffic, shared cache keys across tenants, or a deployment that invalidates half your cache simultaneously. This article covers three concrete techniques that separate hobby caching from production caching.
1. Cache Tags for Granular Invalidation
Cache tags let you group related entries and flush them as a unit without touching unrelated keys. They require a tag-aware driver — Redis or Memcached.
// Storing tagged entries
Cache::tags(['products', 'tenant:42'])->put(
"product:{$product->id}",
$product,
now()->addHour()
);
// Retrieve with the same tag set
$product = Cache::tags(['products', 'tenant:42'])
->get("product:{$product->id}");
// Flush only tenant 42's product cache on a write
Cache::tags(['tenant:42', 'products'])->flush();
The key insight: tag tenant:{id} on every cache write scoped to that tenant. A single flush() call after a bulk import clears exactly what needs clearing — nothing more.
Gotcha:
Cache::tags()->flush()in Redis does not delete keys immediately; it invalidates the tag reference, making tagged keys unreachable. Key memory is reclaimed by Redis's eviction policy. Monitorused_memoryif you flush large tag sets frequently.
2. Preventing Cache Stampedes with Atomic Locks
A stampede happens when a hot key expires and dozens of workers simultaneously miss the cache, all hitting the database at once. Laravel's Cache::lock() solves this cleanly.
use Illuminate\Support\Facades\Cache;
function getExpensiveReport(int $id): array
{
$key = "report:{$id}";
$cached = Cache::get($key);
if ($cached !== null) {
return $cached;
}
// Only one worker rebuilds; others wait up to 5 seconds
return Cache::lock("lock:{$key}", 10)->block(5, function () use ($key, $id) {
// Re-check after acquiring the lock
if ($hit = Cache::get($key)) {
return $hit;
}
$data = DB::table('reports')->where('id', $id)->first();
Cache::put($key, $data, now()->addMinutes(30));
return $data;
});
}
The double-check inside the lock is mandatory. Without it, every worker that was queued behind the lock rebuilds the cache anyway.
block(5) throws LockTimeoutException if the lock is not acquired within 5 seconds — catch it and fall back to a direct DB read rather than letting the request fail.
3. The Cache-Aside Pattern in a Service Class
Inlining cache logic in controllers couples your caching strategy to your HTTP layer. Extract it into a dedicated repository or service:
final class CachedProductRepository
{
public function __construct(
private readonly ProductRepository $inner,
private readonly Repository $cache,
) {}
public function findById(int $id): ?Product
{
return $this->cache
->tags(['products'])
->remember(
"product:{$id}",
now()->addMinutes(60),
fn () => $this->inner->findById($id)
);
}
public function invalidate(int $id): void
{
$this->cache->tags(['products'])->forget("product:{$id}");
}
}
Bind it in a service provider:
$this->app->bind(ProductRepository::class, function ($app) {
return new CachedProductRepository(
inner: $app->make(EloquentProductRepository::class),
cache: $app->make('cache.store'),
);
});
Now your controllers depend on ProductRepository — the caching layer is invisible to them and trivially swappable in tests.
Key Takeaways
- Cache tags enable surgical invalidation; always tag by tenant and entity type in multi-tenant apps.
- Atomic locks with
block()prevent stampedes; always re-check the cache after acquiring the lock. - Cache-aside in a dedicated class keeps controllers clean and makes the caching strategy testable in isolation.
- Catch
LockTimeoutExceptionand degrade gracefully — never let a lock failure become a 500. - Prefer
now()->addMinutes()over integer TTLs; it reads clearly and respectsCarbontimezone config.