Laravel Performance Optimization: 15 Techniques That Work (2026)

Khimananda Oli 9 min read DevOps
Laravel Performance Optimization: 15 Techniques That Work (2026)

By Khimananda Oli | Last reviewed: August 2026

A Laravel app feels fast on your laptop with two rows in the database, then crawls the moment real traffic and a real dataset arrive. Most of the slowdown is not the framework — it is uncached config, N+1 queries, missing indexes, and slow work running inside the request instead of a queue. This guide walks through 15 Laravel performance optimization techniques that actually move the numbers on Laravel 10/11 and PHP 8.4, each with the reason it works and the exact code to apply it. If you would rather have this audited for you, see the Laravel performance and DevOps services.

RequestHTTP inOPcacheprecompiledbytecodeconfig +route cacheskip parsingApp logiccontrollerresponsecachereuseEach cache layer removes work from the hot path — repeat requests can skip most of the app entirely.
The Laravel request lifecycle with performance cache layers: OPcache, config and route caches, and response caching each strip work off the hot path.

What is the fastest way to speed up a Laravel application?

The single fastest change is caching what Laravel otherwise rebuilds on every request. In production, config, routes, and compiled views should never be parsed from source on each hit. One command bakes them all:

php artisan optimize
# runs config:cache, route:cache, view:cache and event:cache together

# clear them again during a deploy before re-caching:
php artisan optimize:clear

Never run optimize or config:cache in local development — once config is cached, env() calls outside config files return null. Cache in production only, as the final step of a deploy, which pairs naturally with an automated pipeline like the one in this GitLab CI/CD pipeline for Laravel walkthrough.

The 15 techniques at a glance

  1. Cache config, routes, views and events with artisan optimize.
  2. Enable and tune OPcache for the PHP-FPM runtime.
  3. Add OPcache preloading for the framework's hot classes.
  4. Fix N+1 queries with eager loading (with()).
  5. Select only the columns you need, and paginate large sets.
  6. Add database indexes to filtered and joined columns.
  7. Chunk or lazily stream large query results.
  8. Cache expensive query results in Redis.
  9. Move sessions and the cache store to Redis.
  10. Offload slow work (email, exports, APIs) to queue workers.
  11. Cache whole responses for anonymous, read-heavy pages.
  12. Bundle and minify front-end assets with Vite.
  13. Serve static assets and images through a CDN.
  14. Run Laravel Octane for a persistent, booted application.
  15. Measure with Debugbar, Telescope and query counts before and after.

How do you fix slow database queries in Laravel?

Slow queries, not PHP, are the usual culprit behind a sluggish page. Three problems cause most of it: N+1 queries, over-fetching columns, and missing indexes.

Technique 4 — eliminate N+1 queries with eager loading

An N+1 query happens when you load a list, then trigger one extra query per row to fetch a relationship. Fifty posts with a lazy author access become 51 queries. Eager loading collapses that to two:

# N+1: 1 query for posts + 1 per post for the author
$posts = Post::all();
foreach ($posts as $post) {
    echo $post->author->name; # fires a query each iteration
}

# Fixed: 2 queries total, regardless of row count
$posts = Post::with('author')->get();

# Guard against it app-wide (Laravel 10/11) in a service provider:
Model::preventLazyLoading(! app()->isProduction());

preventLazyLoading throws in local and staging the moment a relationship is accessed without eager loading, so N+1 problems surface in development instead of production.

N+1 problem (51 queries)SELECT * postsauthor #1author #2author #3… ×NEager loading (2 queries)SELECT * postsSELECT * authorsWHERE id IN (1,2,3,…) — one batched query
Fixing the N+1 query problem: eager loading with with() turns one query per row into a single batched WHERE id IN (…) lookup.

Technique 5 — select only what you need and paginate

Fetching every column and every row wastes memory and bandwidth. Ask for the columns the view uses, and never return an unbounded list:

# Over-fetching: all columns, all rows
$users = User::all();

# Lean: only needed columns, paginated
$users = User::query()
    ->select(['id', 'name', 'email'])
    ->where('active', true)
    ->paginate(25);

Technique 6 — add indexes to filtered and joined columns

Any column used in a WHERE, ORDER BY, or JOIN should be indexed. Without an index, MySQL scans the whole table. Add them in a migration:

Schema::table('posts', function (Blueprint $table) {
    $table->index('user_id');            # foreign key lookups
    $table->index(['status', 'published_at']); # composite for filtered lists
});

Confirm the index is actually used by prefixing the query with EXPLAIN — you want ref or range in the type column, not ALL (a full scan).

Technique 7 — chunk large result sets

Processing tens of thousands of rows at once exhausts memory. chunkById and lazy() stream them in batches:

# Process 1,000 rows at a time without loading them all
User::where('active', true)->chunkById(1000, function ($users) {
    foreach ($users as $user) {
        # work on each user
    }
});

# Or stream one model at a time via a generator
foreach (User::lazy() as $user) {
    # constant memory footprint
}

How do caching and Redis improve Laravel performance?

Once queries are lean, the next gains come from not repeating work. Redis is fast in-memory storage that serves as Laravel's cache, session, and queue backend.

Technique 8 — cache expensive query results

Wrap a costly, rarely-changing query in Cache::remember. The closure runs only on a cache miss:

use Illuminate\Support\Facades\Cache;

$stats = Cache::remember('dashboard.stats', now()->addMinutes(10), function () {
    return [
        'users'    => User::count(),
        'revenue'  => Order::where('paid', true)->sum('total'),
    ];
});

Technique 9 — put the cache and sessions on Redis

The default file cache and session drivers hit disk and do not scale across servers. Point both at Redis in your .env:

CACHE_STORE=redis
SESSION_DRIVER=redis
QUEUE_CONNECTION=redis
REDIS_CLIENT=phpredis   # the C extension is faster than predis

The phpredis PHP extension is noticeably faster than the pure-PHP predis client and is the recommended default on Laravel 10/11.

How do queues and workers speed up Laravel requests?

A web request should return in milliseconds. Anything slow — sending mail, generating a PDF, calling a third-party API, resizing an image — belongs on a queue, so the user gets an instant response while a worker does the heavy lifting in the background.

Technique 10 — offload slow work to a queue

# Instead of sending mail inline (blocks the response):
# Mail::to($user)->send(new WelcomeMail($user));

# Queue it — the request returns immediately:
Mail::to($user)->queue(new WelcomeMail($user));

# Or dispatch a job:
ProcessPodcast::dispatch($podcast)->onQueue('media');

Run a persistent worker in production (kept alive by Supervisor or systemd), not queue:work in a terminal:

php artisan queue:work redis --queue=default,media --tries=3 --max-time=3600
Web requestdispatch jobRedis queuepending jobsWorkersends mail / PDFinstant response to userThe request returns in milliseconds; the worker runs the slow job separately.
Offloading slow work to a queue worker: the request dispatches a job to Redis and returns instantly while a background worker handles the heavy task.

Technique 11 — cache whole responses for read-heavy pages

For anonymous, mostly-static pages (a blog index, a marketing page), caching the full rendered response skips the controller and database entirely on repeat hits. A middleware pattern:

$key = 'page:' . sha1($request->fullUrl());

return Cache::remember($key, now()->addHour(), function () use ($request) {
    return response($this->renderPage($request));
});

Which server-level tweaks make Laravel faster?

The runtime and delivery layer matter as much as the code.

Technique 2 & 3 — enable OPcache and preloading

OPcache stores compiled PHP bytecode in memory so scripts are not recompiled on every request — often the biggest single production win. In php.ini:

opcache.enable=1
opcache.memory_consumption=256
opcache.max_accelerated_files=20000
opcache.validate_timestamps=0   ; production only — clear OPcache on deploy
opcache.jit=1255                 ; PHP 8.4 JIT
opcache.jit_buffer_size=128M

With validate_timestamps=0, PHP never checks files for changes, so you must reload PHP-FPM (or clear OPcache) on each deploy. Preloading goes further by loading the framework's hot classes into shared memory once at startup via opcache.preload.

Technique 12 & 13 — bundle assets and serve them from a CDN

Compile and minify CSS/JS for production so browsers download fewer, smaller files, then push static assets to a CDN close to users:

npm run build        # Vite: minified, hashed, versioned assets

# .env — rewrite asset URLs to the CDN host
ASSET_URL=https://cdn.example.com

Technique 14 — run Laravel Octane for a persistent app

Traditional PHP-FPM boots the framework on every request. Octane keeps a booted application resident in memory using Swoole or FrankenPHP, so each request skips the bootstrap and reuses warm objects — a large throughput gain for API-heavy apps:

composer require laravel/octane
php artisan octane:install --server=frankenphp
php artisan octane:start --workers=4 --max-requests=500

Octane changes the memory model: the container persists between requests, so avoid storing request-specific state in singletons and watch for memory leaks. Test thoroughly before switching a mature app.

How do you measure Laravel performance improvements?

Optimise against numbers, not hunches. Technique 15 is measurement itself.

  • Laravel Debugbar — shows query count, timings, and memory per page in local development. If a page fires 60 queries, you have an N+1 problem to hunt.
  • Laravel Telescope — records slow queries, jobs, and requests in staging.
  • Count queries in a test to lock in a fix and prevent regressions:
use Illuminate\Support\Facades\DB;

DB::enableQueryLog();
$this->get('/dashboard');
$this->assertLessThan(10, count(DB::getQueryLog()));

Always record a baseline (response time and query count) before a change and compare after — that is the only way to know a technique actually worked for your workload.

Conclusion

Laravel performance optimization is not one silver bullet; it is a stack of small, measurable wins. Start with the cheapest and highest-impact ones — artisan optimize, OPcache, and fixing N+1 queries — then add indexes, Redis, and queues as your traffic grows, and reach for Octane only once the basics are in place. Measure before and after each change so you know it helped. If you want a data-backed performance audit of your Laravel app, get in touch or browse the performance and DevOps case studies for real-world results.

Frequently Asked Questions

Laravel performance optimization is the practice of reducing an application's response time and resource use through caching, query tuning, background queues, and server configuration. It covers config and route caching, fixing N+1 queries, adding indexes, using Redis, and running OPcache or Octane, each measured against a baseline.

Run php artisan optimize in production. It caches config, routes, views, and events so the framework stops rebuilding them on every request. Combined with OPcache, it is the cheapest change with the largest immediate effect on response time.

No. Caching config locally breaks env() calls outside config files, which then return null, and cached routes ignore new route changes. Run optimize only in production as the final deploy step, and clear it with optimize:clear when deploying new code.

An N+1 query happens when you load a list of records, then trigger one extra database query per record to read a relationship. Fifty rows become 51 queries. Eager loading with with() batches the relationship into a single query, so the total drops to two.

Eager load the relationship with Model::with('relation')->get(), which fetches all related rows in one batched query. Enable Model::preventLazyLoading() in non-production environments so any missed eager load throws an error during development instead of silently slowing production.

Yes, significantly. Any column used in a WHERE, ORDER BY, or JOIN should be indexed, or the database scans the entire table on every query. Add indexes in a migration and verify usage with EXPLAIN — you want the type column to read ref or range, not ALL.

OPcache stores compiled PHP bytecode in shared memory so scripts are not recompiled on each request. It is often the biggest single production speedup. Set validate_timestamps=0 in production for maximum gain, but remember to reload PHP-FPM or clear OPcache on every deploy.

Yes. The default file cache hits disk and does not scale across servers. Set CACHE_STORE=redis and SESSION_DRIVER=redis so cache reads and session lookups happen in memory. Use the phpredis C extension rather than predis for the best throughput on Laravel 10 and 11.

Move any task that takes more than a few milliseconds or depends on an external service — sending email, generating PDFs, resizing images, calling third-party APIs. Dispatch it as a queued job so the request returns instantly and a background worker processes the slow work separately.

Run php artisan queue:work under a process manager like Supervisor or systemd so it stays alive and restarts on failure. Use flags such as --tries=3 and --max-time=3600, and restart workers on each deploy with php artisan queue:restart so they pick up new code.

Octane keeps a booted Laravel application resident in memory using Swoole or FrankenPHP, so requests skip the framework bootstrap and reuse warm objects. It boosts throughput for API-heavy apps but changes the memory model, so avoid stateful singletons and test carefully before adopting it.

Wrap the query in Cache::remember('key', $ttl, fn () => ...). The closure runs only on a cache miss and the result is served from Redis until the TTL expires. Use it for costly, rarely-changing data like dashboard counts or aggregate reports.

Do not load everything at once. Use chunkById() to process rows in fixed batches, or lazy() to stream one model at a time via a generator. Both keep memory roughly constant regardless of how many rows the query returns.

Use Laravel Debugbar in development to see query counts and timings, and Telescope in staging to catch slow queries and jobs. Record a baseline response time and query count before each change and compare afterward, so you know the optimization actually helped.

Route caching requires controller-based routes; it fails if any route uses a closure. Convert closure routes to controller actions before running route:cache. Config and view caching have no such restriction and can be enabled independently in production.