A broken cache rarely announces itself. The app keeps working — it just gets slower and your database works harder, because the layer that was supposed to absorb reads has quietly stopped. By the time someone notices, the cause (a changed key, a TTL set to zero, a deploy that flushes everything) is days old. Monitoring the cache means watching the hit rate and latency so a cache that stops caching shows up as a number instead of a mystery slowdown.
Listen to cache events
Laravel fires events on every cache operation. Register listeners in AppServiceProvider::boot.
use Illuminate\Cache\Events\CacheHit;
use Illuminate\Cache\Events\CacheMissed;
use Illuminate\Cache\Events\KeyWritten;
use Illuminate\Support\Facades\Event;
public function boot(): void
{
Event::listen(CacheHit::class, function (CacheHit $e) {
// Group by pattern, not raw key — 'user:742' → 'user:?'
$pattern = preg_replace('/:\d+/', ':?', $e->key);
metrics()->increment('cache.hit', tags: ['pattern' => $pattern]);
});
Event::listen(CacheMissed::class, function (CacheMissed $e) {
$pattern = preg_replace('/:\d+/', ':?', $e->key);
metrics()->increment('cache.miss', tags: ['pattern' => $pattern]);
});
}Don't store raw keys. At scale, per-key metrics are unmanageable (millions of unique keys). Normalize to patterns and aggregate.
Prevent cache stampedes
A popular cached value expires. 100 requests simultaneously try to recompute it, all hitting the database. Your DB melts.
Stampede-prone — don't do this at scale
$value = Cache::remember('dashboard:stats', 300, function () {
return DB::table('orders')->selectRaw('...')->get(); // expensive
});Stampede-safe with atomic lock
$value = Cache::get('dashboard:stats');
if ($value === null) {
$lock = Cache::lock('dashboard:stats:lock', 10);
if ($lock->get()) {
try {
$value = DB::table('orders')->selectRaw('...')->get();
Cache::put('dashboard:stats', $value, 300);
} finally {
$lock->release();
}
} else {
// Another worker is recomputing — wait or fall back
$value = Cache::get('dashboard:stats:stale') ?? [];
}
}Pick a shared cache store
Multi-server setups need a shared cache — file and database drivers are per-server and will cause hard-to-debug inconsistencies.
'default' => env('CACHE_STORE', 'redis'),
'stores' => [
'redis' => [
'driver' => 'redis',
'connection' => 'cache',
'lock_connection' => 'default',
],
],Key things to alert on
- Hit rate drop — from 90% to 60% suggests TTL or key-generation bug
- Latency spike — cache p95 > 20ms usually means the Redis box is under pressure
- Eviction rate — Redis
evicted_keysgrowing means cache is too small - Key cardinality explosion — wildcard patterns ballooning (e.g. a cached-per-user value instead of per-plan)
The cache that wasn't caching
A common version of this: someone caches a per-user value with Cache::remember("dashboard:{$user->id}", ...). It works in testing, where there's one user. In production every user gets their own key, so the hit rate for that pattern sits near zero — each key is read once, then expires before the same user comes back — and you're paying the full cost of caching with none of the benefit. The database load looks like there's no cache at all, because for that pattern there effectively isn't.
You only catch this if you group cache metrics by pattern. A per-key view drowns in millions of unique keys; a per-pattern view shows dashboard:? at a 3% hit rate and the problem names itself. The fix is usually to cache at a coarser granularity (per plan, per team) or to accept that this particular value isn't worth caching.
THE EASY WAY
NightOwl tracks cache ops per request
NightOwl's cache watcher logs every hit, miss, and write with duration, tied to the request that triggered it. You see per-route cache hit rate, top missed patterns, and the slowest cache calls across all traffic — no custom event listeners required.
composer require nightowl/agent
php artisan nightowl:install