Optimizing Laravel Performance: A Practical Guide
Laravel performance is a sequence, not a trick: measure, fix the queries, then reach for caching, queues and Octane. Here is the full order with code for each step.
Laravel performance work is a sequence, not a bag of tricks. You measure to find the real bottleneck, fix the database access that causes most slowness, then layer on caching, queues and finally Octane. Doing it in that order means each step compounds the previous one instead of hiding a problem you never fixed.
Measure before optimizing
Never guess at a bottleneck. Use Telescope on staging to inspect queries, requests and jobs in detail (keep it behind auth, never open in production). Use Debugbar or Clockwork locally to see the query count and timing per request. For production, the database slow query log and a quick DB::listen probe tell you which queries actually hurt.
N+1 queries and eager loading
The single most common cause of a slow Laravel endpoint is a loop that lazy-loads a relation, turning one page into dozens of near-identical queries. Fix it with with, withCount and loadMissing, and in non-production call preventLazyLoading so any lazy load throws and the problem surfaces in tests instead of production.
// N+1: one query for orders, then one per order for the customer
$orders = Order::latest()->limit(50)->get();
foreach ($orders as $order) {
echo $order->customer->name;
}
// Fixed: two queries total
$orders = Order::with('customer')->latest()->limit(50)->get();
// app/Providers/AppServiceProvider.php
Model::preventLazyLoading(! app()->isProduction());Database indexes
Run EXPLAIN on the slow queries. Index the columns used in WHERE, ORDER BY and JOIN. For a composite index, order the columns from most selective to least, and match the order the query filters in. A query that filters and sorts on the same columns an index covers never touches the table rows.
Schema::table('orders', function (Blueprint $table) {
$table->index(['status', 'created_at']); // filter by status, sort by date
});Query hygiene for large datasets
Select only the columns you use, so a wide table does not ship megabytes you throw away. Never load a large result set into memory at once: use lazy or cursor to stream rows, chunkById for batch jobs, and paginate every list endpoint.
Order::where('status', 'pending')
->select(['id', 'customer_id', 'total'])
->lazy()
->each(fn (Order $order) => $order->reconcile());Targeted caching
Cache expensive reads that are repeated far more often than they change, not every query. Cache tags let you invalidate one slice without flushing everything, which keeps writes cheap. Cache the computed result, not the whole HTTP response, unless the page is fully cacheable.
$topProducts = Cache::tags(['products', 'dashboard'])
->remember('top-products', now()->addMinutes(30), function () {
return Product::withCount('orders')
->orderByDesc('orders_count')
->limit(10)
->get();
});
// Invalidate only the products slice
Cache::tags(['products'])->flush();Move heavy work to queues
Emails, exports, PDF generation and third-party calls do not belong in the request cycle. Dispatch them to a queue and return immediately. Horizon gives real-time metrics on throughput, wait time and failures, so jobs stop disappearing silently into failed_jobs. Use job batching when a task fans out into many units of work.
HTTP-level caching
Add ETag and Cache-Control headers to GET endpoints whose data changes slowly, so clients and proxies can serve a 304 instead of a full response. Serve assets from a CDN. For pages that are identical for every visitor, a full response cache in front of Laravel removes the framework from the hot path entirely.
Deployment-time caches
On every deploy, run config:cache, route:cache, view:cache and event:cache so Laravel does not rebuild them per request. Enable OPcache on the server, and install with composer install --optimize-autoloader --no-dev. These are free wins that are easy to forget.
Octane, last
Traditional PHP-FPM boots the whole framework on every request. Octane, on FrankenPHP or Swoole, keeps the application in memory between requests and cuts a meaningful slice off response time under load. It is a multiplier on an app that is already fast, not a fix for a slow one, and it introduces state-leak pitfalls: no mutable static state, careful handling of container singletons.
// Dangerous with Octane: static state persists across requests
class ReportCache
{
private static array $cache = []; // leaks between requests, avoid
}| Step | Effort | Typical gain |
|---|---|---|
| Profile the slow requests | Low | Tells you where the rest of the effort goes |
| Fix N+1 and add indexes | Low to medium | Often the largest single improvement |
| Query hygiene (select, lazy, paginate) | Low | Removes memory spikes and timeouts |
| Targeted caching with tags | Medium | Large on read-heavy endpoints |
| Offload work to queues | Medium | Cuts response time on write endpoints |
| Deployment caches and OPcache | Low | Small but free, every request |
| Laravel Octane | Medium to high | Multiplies an already-fast app under load |
Measure before optimizing. Octane will not fix N+1 queries and caching will not fix a missing index. Profile first, fix the root cause, then reach for the tools that multiply an app that is already fast.
FAQ
- Where do I start to speed up a Laravel app?
- Measure first with Telescope or Debugbar to find the slowest requests, then fix N+1 queries and missing indexes. Those two account for most real-world slowness. Caching and Octane come after, and they multiply an already-fast app rather than rescuing a slow one.
- Is Laravel Octane worth it?
- Yes, once the database work is optimized. Octane keeps the framework booted between requests and cuts a meaningful slice off response time under load. On an app still doing N+1 queries it just runs the slow code faster, and the state-leak pitfalls are real, so it is the last step, not the first.
- How do I detect N+1 queries?
- Debugbar or Telescope show the query count per request; a list endpoint firing dozens of near-identical queries is the signature. In non-production, call Model::preventLazyLoading() so a lazy load throws and the N+1 surfaces in tests and local runs.
- Does Laravel caching slow down writes?
- Cache reads are fast; the cost is invalidation. If every write flushes a broad cache, you trade read speed for write churn. Cache tags let you invalidate just the affected slice, which keeps writes cheap while the hot reads stay cached.
- FrankenPHP or Swoole for Octane?
- Both keep the framework in memory. FrankenPHP is simpler to deploy, a single binary with built-in HTTPS and workers, and is the current default recommendation. Swoole exposes more primitives such as coroutines and tables if you need them. Start with FrankenPHP.
Follow the order: measure, fix queries and indexes, tidy the data access, then cache, queue and cache HTTP, and only then Octane. Most Laravel apps get the bulk of their speed back in the first two steps, and the rest of the list keeps it fast as traffic grows.
Need help with this topic? Technical Audit
Discover this service →