Caching is easy to add and surprisingly easy to get subtly wrong. The hard part was never writing Cache::remember() — it's deciding when that cached value stops being true, and making sure it actually gets cleared at that moment instead of five minutes or five hours later.
Most caching bugs don't look like caching bugs when they're reported. They arrive as "the dashboard shows the wrong total" or "I updated my profile and it still shows the old name," and the investigation usually ends at a cache key nobody remembered to invalidate. Treating invalidation as part of the feature — not an afterthought added once something breaks — is what keeps that investigation rare.
Start With the Question: Who Invalidates This?
Every cached value needs an answer to "what event makes this stale?" If the answer is vague — "whenever the data changes somewhere" — that's a sign the cache key or invalidation strategy needs more thought before it ships, not after a user reports stale data.
Cache Tags for Grouped Invalidation
Redis and Memcached drivers support cache tags, which let you invalidate a whole family of keys without tracking each one individually.
public function summaryFor(Organization $organization): array
{
return Cache::tags(['organization:'.$organization->id, 'invoices'])
->remember(
"invoice-summary:{$organization->id}",
now()->addMinutes(30),
fn () => $this->buildSummary($organization),
);
}
When an invoice changes, you don't need to know the exact cache key that summary used — you just flush the tag:
Cache::tags(['organization:'.$invoice->organization_id])->flush();
This scales well because the tag, not the key, is what the invalidating code needs to know about.
Stale-While-Revalidate to Avoid Stampedes
A classic failure mode: a popular cache key expires, and a burst of simultaneous requests all miss the cache at once and hammer the database rebuilding the same value. Laravel's flexible() method on the cache repository handles this directly.
$stats = Cache::flexible('dashboard-stats', [60, 300], function () {
return $this->metrics->computeExpensiveAggregate();
});
The first number is how long the value is considered fresh; the second is how much longer a stale value can still be served while one request rebuilds it in the background. Everyone else keeps getting the slightly-stale value instead of piling onto the database at the same moment.
Cache::lock('rebuild:dashboard-stats', 10)->get(fn () => ...) — achieves the same thing: only one process rebuilds, the rest wait briefly or serve stale data.TTLs Are a Backstop, Not a Strategy
Relying purely on a short TTL to "eventually" clear stale data works, but it means users see incorrect data for up to that TTL every single time, by design. Explicit invalidation — clearing the relevant key or tag the moment the underlying data changes — should be the primary mechanism; TTL is there to catch the invalidation paths you missed.
class InvoiceObserver
{
public function saved(Invoice $invoice): void
{
Cache::tags(['organization:'.$invoice->organization_id])->flush();
}
}
Keying Strategy Matters as Much as Invalidation
- Include everything the cached value depends on in the key — a per-user report cached under a key that ignores the user's filters will return the wrong filtered results to someone else.
- Version your cache keys (
v2:invoice-summary:...) when you change what a cached payload contains, so old and new shapes never collide during a deploy. - Avoid caching anything tied to permissions under a key that doesn't include the permission context — it's a quiet way to leak data across roles.
Caching failures rarely show up as errors — they show up as a support ticket saying "the numbers are wrong," hours after the actual change happened. A clear answer to "what invalidates this" for every cached value is what keeps that ticket from ever getting filed.