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 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