Cache invalidation — removing or updating a stale cache entry (one of the hard problems)
Concept
Cache invalidation — the process of removing or updating cached data when the underlying data changes, to prevent serving stale (outdated) responses.
"There are only two hard things in Computer Science: cache invalidation and naming things." — Phil Karlton. It's famously hard because you must ensure cache coherence between the cache and the source of truth.
Strategies:
- TTL (Time-to-Live): Cache expires after X seconds. Simple, but serves stale data during the TTL window. Acceptable for data that doesn't need to be immediately fresh.
- Write-through: Update cache whenever you write to the database. Cache is always fresh. Overhead on writes. Complex if multiple services write.
- Write-behind: Update cache immediately, write to DB asynchronously. Fast writes, risk of data loss.
- Event-based invalidation: When an event fires (
OrderUpdated), invalidate the relevant cache keys. Real-time freshness. More complex. - Cache-aside with explicit invalidation: When updating a record, call
Cache::forget('key'). Cache is stale only between the write and theforget().
The problem with TTL: Short TTL = fresh data but many cache misses. Long TTL = more cache hits but potentially stale data. The right TTL depends on how often the data changes and how stale data can be tolerated.
Cache tags: Group related cache items under a tag, then invalidate by tag. Cache::tags(['product:1'])->flush() deletes all caches tagged with product:1. Useful when one model change invalidates many cache entries.
Partial invalidation: Avoid flushing too much. Cache::flush() in production is catastrophic — all cache gone, massive DB load spike. Always invalidate specific keys or tags.
Code Example
<?php
// STRATEGY 1: TTL only — data may be up to 3600 seconds stale
class ProductRepository
{
public function find(int $id): Product
{
return Cache::remember("product:{$id}", 3600, fn() => Product::findOrFail($id));
}
}
// Simple, but if someone updates the product, cache still returns old data for up to 1 hour
// STRATEGY 2: Event-based invalidation
class ProductObserver
{
public function updated(Product $product): void
{
Cache::forget("product:{$product->id}");
Cache::tags(['products'])->flush(); // also invalidate the list
}
public function deleted(Product $product): void
{
Cache::forget("product:{$product->id}");
}
}
// Register the observer
// App\Providers\AppServiceProvider::boot():
Product::observe(ProductObserver::class);
// STRATEGY 3: Cache tags for grouped invalidation
class ProductRepository
{
public function find(int $id): Product
{
return Cache::tags(["product:{$id}", 'products'])
->remember("product:{$id}:detail", 3600, fn() => Product::with('variants')->findOrFail($id));
}
public function list(): \Illuminate\Database\Eloquent\Collection
{
return Cache::tags(['products'])->remember('products:all', 3600, fn() => Product::all());
}
public function update(int $id, array $data): Product
{
$product = Product::findOrFail($id)->fill($data)->save();
Cache::tags(["product:{$id}", 'products'])->flush(); // invalidates both detail and list
return $product;
}
}
// STRATEGY 4: Write-through
public function create(array $data): Product
{
$product = Product::create($data);
Cache::put("product:{$product->id}", $product, 3600); // immediately cache
Cache::tags(['products'])->forget('products:all'); // invalidate list
return $product;
}
// AVOID: flushing everything
Cache::flush(); // DANGEROUS in production — all cache gone at once = DB stampede