Caching, performance and debugging

Cache stores and tags, remembering values correctly, route and config caching, hunting N+1 queries, Telescope, and Octane trade-offs.

Caching correctly

// remember: read-through with a TTL
$books = Cache::remember("author:{$author->id}:books", now()->addMinutes(30),
    fn () => $author->books()->published()->get());

// tags need redis, memcached or dynamodb
Cache::tags(['books', "author:{$author->id}"])->flush();

// atomic lock for expensive recomputation
$lock = Cache::lock('report:daily', 60);
if ($lock->get()) {
    try { Report::buildDaily(); } finally { $lock->release(); }
}

Cache::increment('book:'.$book->id.':views');
Cache::put('settings', $settings, now()->addDay());
  • rememberForever is a leak waiting to happen - always a TTL, even a long one.
  • The database cache store serialises into your primary database and adds contention; use it only when Redis is genuinely unavailable.
  • Invalidate on the write path. A cache without eviction is a bug with a delay fuse.
⚠️
Cache the serialised DTO or array, never an Eloquent model with its loaded relations. A stale model in the cache bypasses casts, hides pending changes, and reappears with relations that no longer exist.

Finding the actual bottleneck

php artisan config:cache
php artisan route:cache
php artisan view:cache
php artisan event:cache
php artisan optimize            # all of the above
php artisan optimize:clear      # and clear them

php artisan telescope:install && php artisan migrate
php artisan db:monitor --databases=mysql --max=100
SymptomLikely causeCheck
Slow list pageN+1 queriesQuery count in Telescope or Debugbar
High memory in a workerLoading a whole tablelazyById() instead of get()
Slow first request onlyUncached config and routesoptimize in the deploy script
High DB CPU with simple queriesMissing indexEXPLAIN the generated SQL
Slow responses, fast queriesSerialisation or view renderingProfiler step timings
// queue a slow side effect so the response does not wait
ProcessCover::dispatch($book);

// select only what the template needs
$books = Book::query()
    ->select(['id', 'title', 'author_id'])
    ->with('author:id,name')
    ->paginate(25);

Octane and long-running workers

composer require laravel/octane
php artisan octane:install --server=frankenphp
php artisan octane:start --workers=auto --max-requests=500
  • Octane keeps the application in memory, so state that used to be per-request now leaks: static properties, singletons holding a user, and container bindings that captured a request.
  • Reset what you cache: list your singletons and add Octane::flush() or a RequestReceived listener that clears them.
  • Measure before adopting it. Octane removes framework bootstrap cost, which is a few milliseconds on a warm OPcache - the win is real, but smaller than a single unindexed query.

FAQ

When should I reach for cache tags?
When one write must invalidate several related entries and you cannot name them all - for example every listing that includes a given tag. Tags require a taggable driver, so the code must not rely on them if the cache store could change.
Is Redis always the answer?
For shared caching and locks in a multi-instance deployment, usually yes. For a single instance, an in-process array cache plus OPcache often removes the network hop entirely - measure your actual cache hit ratio first.

Eloquent relationships, casts and query scopes Deployment and production hardening

Last refreshed 2026-09-18.