Jay GajjarSenior Software Engineer

Laravel performance checklist for 2026: N+1, indexes, caching, queues and OPcache

A Laravel performance checklist for 2026: find slow queries, fix N+1 and indexes, cache, queue slow work, tune OPcache and know when Octane pays off.

By Jay Gajjar9 min read

This Laravel performance checklist starts with measuring, because most slow Laravel apps are slow for two or three specific reasons, not fifty. In my experience the database is usually the main one: N+1 queries, missing indexes, or tables that have grown past what a single index can handle. After that come production caching (php artisan optimize), OPcache, queues for slow work, and only then bigger moves like Octane or table partitioning. The checklist goes through each in the order I would work on an app, with code for current Laravel versions (11 to 13). If you would rather have it done for you, my Laravel performance optimization service follows the same order and ends with a before-and-after report.

Quick answer: the Laravel performance checklist in eight steps

  1. Measure: find the slowest pages, endpoints and queries before changing anything.
  2. Remove N+1 queries with eager loading, and turn on lazy-loading protection outside production.
  3. Add the right indexes and check them with EXPLAIN.
  4. Run php artisan optimize and composer install --optimize-autoloader on every deploy.
  5. Cache expensive results in Redis, with a clear invalidation rule.
  6. Move emails, PDFs, imports and API calls to queues.
  7. Enable and size OPcache, and tune PHP-FPM workers to your RAM.
  8. Consider Octane or partitioning only when measurements point to them.

Measure first: find what is actually slow

Guessing wastes days. Spend the first hour collecting numbers:

  • Laravel Debugbar in local development shows every query, its time and how many times it ran on a page. Duplicate queries are the signal for N+1.
  • Laravel Telescope records requests, queries, jobs and exceptions in a local or staging environment.
  • Laravel Pulse gives a production dashboard of slow requests, slow queries, slow jobs and the busiest users.
  • An APM or Laravel Nightwatch if you want hosted, continuous monitoring with traces.

You can also log slow queries yourself with a few lines in AppServiceProvider:

use Illuminate\Database\Events\QueryExecuted;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;

public function boot(): void
{
    DB::listen(function (QueryExecuted $query) {
        if ($query->time > 200) { // milliseconds
            Log::warning('Slow query', [
                'sql' => $query->sql,
                'bindings' => $query->bindings,
                'ms' => $query->time,
            ]);
        }
    });
}

On the MySQL side, enabling the slow query log (slow_query_log = 1, long_query_time = 0.5) catches queries from cron jobs and workers too. Write down the response time of your five most important pages now, so you can prove each change helped.

Fix N+1 queries with eager loading

An N+1 problem is one query for a list, then one more query per row for a relationship. Fifty orders with their customers becomes 51 queries:

// N+1: one query for the orders, then one per order for its customer
$orders = Order::latest()->limit(50)->get();

foreach ($orders as $order) {
    echo $order->customer->name;
}

// Two queries in total, and only the columns the page needs
$orders = Order::with('customer:id,name')->latest()->limit(50)->get();

When you select columns on an eager load, always include id and the foreign keys, or the relationship cannot be matched. For counts, use withCount() instead of loading whole collections:

$customers = Customer::withCount('orders')->paginate(25);
// $customer->orders_count, no extra queries

Then stop N+1 problems from coming back. In AppServiceProvider::boot(), make lazy loading throw an exception outside production, so it fails in development and tests instead of slowing down production:

use Illuminate\Database\Eloquent\Model;

Model::preventLazyLoading(! $this->app->isProduction());

Recent Laravel versions also offer Model::automaticallyEagerLoadRelationships(), which batches lazy loads for a whole collection automatically. It is a useful safety net for an older codebase with many N+1 spots, but explicit with() calls keep the queries visible in the code, so I still prefer them for new work.

Laravel slow queries: indexes and EXPLAIN

Once the query count is sane, look at the slowest individual queries. Most Laravel slow queries come down to a missing or unusable index. If a page filters orders by status and date, a composite index in that order helps:

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    public function up(): void
    {
        Schema::table('orders', function (Blueprint $table) {
            $table->index(['status', 'created_at']);
        });
    }

    public function down(): void
    {
        Schema::table('orders', function (Blueprint $table) {
            $table->dropIndex(['status', 'created_at']);
        });
    }
};

Check that MySQL actually uses it. The query builder can run EXPLAIN for you:

Order::where('status', 'paid')
    ->where('created_at', '>=', now()->subDays(30))
    ->explain()
    ->dd();

In the output, type: ALL means a full table scan, key shows the index used (or NULL), and rows estimates how many rows MySQL reads. Common reasons an index is ignored:

  • Functions on the column. whereDate('created_at', today()) compiles to date(created_at) = ?, which cannot use an index on created_at. Use a range instead: where('created_at', '>=', today())->where('created_at', '<', today()->addDay()).
  • Wrong column order. A composite index on (status, created_at) helps queries that filter on status. It does not help a query that filters on created_at alone.
  • Leading wildcards. LIKE '%term' cannot use a normal index. Use full-text search or Laravel Scout for real search.

Two more database habits pay off on large tables:

  • Process in chunks. chunkById(1000, ...) or lazyById(1000) keep memory flat in exports and maintenance commands, where get() on a million rows would not.
  • Paginate without OFFSET. cursorPaginate() avoids the COUNT(*) query and deep offsets, which matters for long lists and APIs.

Partition very large tables

When a table holds years of time-series rows and most queries ask for a recent date range, MySQL partitioning can keep those queries fast and makes deleting old data cheap. I used table partitioning on the brokerage risk platform, a Laravel 10 back office and risk system for 1,000+ trading accounts with scheduled data pipelines, where this kind of data growth is the normal state.

Laravel's schema builder has no partition syntax, so create the table with a raw statement in a migration:

use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    public function up(): void
    {
        DB::statement(<<<'SQL'
            CREATE TABLE account_snapshots (
                id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
                account_id BIGINT UNSIGNED NOT NULL,
                equity DECIMAL(18, 2) NOT NULL,
                captured_at DATETIME NOT NULL,
                PRIMARY KEY (id, captured_at),
                KEY account_snapshots_account_captured (account_id, captured_at)
            )
            PARTITION BY RANGE COLUMNS (captured_at) (
                PARTITION p2026_09 VALUES LESS THAN ('2026-10-01'),
                PARTITION p2026_10 VALUES LESS THAN ('2026-11-01'),
                PARTITION pmax VALUES LESS THAN (MAXVALUE)
            )
        SQL);
    }

    public function down(): void
    {
        Schema::dropIfExists('account_snapshots');
    }
};

Things to know before you partition:

  • Every primary and unique key must include the partitioning column, which is why the primary key above is (id, captured_at).
  • InnoDB does not support foreign keys on partitioned tables. Enforce those relationships in application code.
  • Queries only get faster when they filter on the partition column, so MySQL can skip partitions. EXPLAIN shows which partitions it reads in the partitions column.
  • New partitions do not appear by themselves. Schedule a command that splits pmax before each month starts (ALTER TABLE ... REORGANIZE PARTITION pmax INTO (...)), and drop old partitions with ALTER TABLE ... DROP PARTITION, which is much faster than a large DELETE.

Partitioning is not a substitute for indexes. Fix indexes first and partition only the few tables where size itself is the problem.

Cache config, routes, views and events

Laravel ships with one command that caches configuration, events, routes and views. Run it on every production deploy:

composer install --no-dev --optimize-autoloader
php artisan optimize

It is the cheapest Laravel speed optimization there is. optimize:clear removes those caches again. One trap: once config is cached, the .env file is no longer loaded, so any env() call outside the config/ files returns null. Move those calls into a config file and read them with config().

Also confirm APP_DEBUG=false in production, and remove dev-only packages from production with --no-dev. Debug mode and Debugbar both add real overhead. If you host on shared hosting rather than a server you control, my guide to deploying Laravel on cPanel or a VPS shows how to run these steps, cron and queue workers on each.

Cache expensive results in Redis

For dashboards, reports and anything computed from many rows, cache the result rather than the query. Store arrays, not Eloquent models: arrays are smaller and avoid object unserialization issues (Laravel 13's default cache config blocks unserializing objects unless you allow-list the classes).

use Illuminate\Support\Facades\Cache;

$stats = Cache::remember('dashboard:stats', now()->addMinutes(5), function () {
    return [
        'orders_today' => Order::where('created_at', '>=', today())->count(),
        'revenue_today' => (float) Order::where('created_at', '>=', today())->sum('total'),
    ];
});

For values that are expensive to rebuild, Cache::flexible('dashboard:stats', [300, 600], fn () => ...) serves the slightly stale value while it refreshes in the background, so no user waits on the rebuild. Decide the invalidation rule at the same time you add the cache: a short TTL, or Cache::forget() in a model observer when the source data changes.

Use Redis (or Memcached) for cache and sessions in production, not the file or database driver, once you have real traffic or more than one server. On Laravel 11 and later the variable is CACHE_STORE=redis, and on older versions it is CACHE_DRIVER.

Move slow work to queues

Anything that takes more than a few hundred milliseconds and the user does not need to wait for belongs in a queued job: emails, PDF generation, imports, exports, scraping jobs, webhooks and third-party API calls (an OpenAI request is a typical example, see calling OpenAI from Laravel).

namespace App\Jobs;

use App\Models\Invoice;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;

class SendInvoicePdf implements ShouldQueue
{
    use Queueable;

    public function __construct(public Invoice $invoice) {}

    public function handle(): void
    {
        // Render the PDF and email it to the customer.
    }
}

Dispatch it with SendInvoicePdf::dispatch($invoice); and keep workers running under Supervisor:

[program:laravel-worker]
process_name=%(program_name)s_%(process_num)02d
command=php /var/www/app/artisan queue:work redis --sleep=3 --tries=3 --max-time=3600
autostart=true
autorestart=true
stopasgroup=true
killasgroup=true
user=www-data
numprocs=2
redirect_stderr=true
stdout_logfile=/var/www/app/storage/logs/worker.log
stopwaitsecs=3600

Restart workers on each deploy with php artisan queue:restart, or they keep running the old code. For large imports, split the work into batches rather than one huge job. On the export operations ERP I led on Laravel 12, bulk imports run in batches for exactly this reason. Horizon is worth adding when you run Redis queues and want to see throughput and failures.

OPcache, PHP-FPM and Octane

OPcache

OPcache keeps compiled PHP in memory, so files are not parsed on every request. Check it is on for PHP-FPM, not just the CLI (the two can read different ini files, so php -i on the command line can mislead), and size it for a Laravel app:

opcache.enable=1
opcache.memory_consumption=256
opcache.interned_strings_buffer=16
opcache.max_accelerated_files=20000
opcache.validate_timestamps=0

With validate_timestamps=0, PHP never checks files for changes, so you must reload PHP-FPM after each deploy (for example sudo systemctl reload php8.4-fpm). The JIT is off by default and rarely helps typical Laravel requests, which spend their time waiting on the database and network. Measure before enabling it.

PHP-FPM workers

pm.max_children caps how many requests PHP handles at once. Set it from memory, not guesswork: divide the RAM you can give PHP by the average memory of one worker. Say 4 GB available and workers averaging 80 MB: about 50 children. Too low and requests queue up; too high and the server swaps, which is far worse.

When Laravel Octane is worth it

Laravel Octane (opens in a new tab) boots the app once and keeps it in memory, served by FrankenPHP, Swoole, Open Swoole or RoadRunner. It removes framework bootstrap time from every request, which pays off for high-traffic APIs where the database work per request is already small. It is not a fix for slow queries, and it needs care: singletons that hold the request or config go stale, and data appended to static arrays leaks memory across requests. It also needs a VPS or container with a process supervisor, not shared hosting. I reach for Octane after the database and caching work is done, not before.

Front-end assets and Core Web Vitals

Server time is only part of what users feel. For Blade-rendered pages, build assets with Vite (npm run build) and load them with the @vite directive, defer scripts that are not needed for the first paint, set width and height on images, and serve static files with compression and long cache headers from Nginx or a CDN. My Laravel SEO guide covers Core Web Vitals for Laravel pages in more detail.

Laravel performance tuning checklist

Area Check How
Measurement Baseline for key pages Debugbar, Pulse, slow query log
Queries No N+1 with(), withCount(), preventLazyLoading()
Indexes Filters and joins use an index explain(), composite indexes
Large tables Bounded memory and fast paging chunkById(), cursorPaginate(), partitioning
Framework Caches built on deploy php artisan optimize
Autoloader Optimized, no dev packages composer install --no-dev --optimize-autoloader
Data cache Expensive results cached Cache::remember(), Redis
Background work Slow tasks queued Jobs, Supervisor, Horizon
PHP OPcache on and sized opcache.* settings
Server FPM workers fit RAM pm.max_children
Front end Built and compressed assets Vite, CDN, image dimensions

Frequently asked questions

Why is my Laravel app slow?

Most often it is the database, and the fastest way to speed up Laravel is to fix that first: N+1 queries, missing indexes or loading far more rows than a page shows. Next come missing production caches, debug mode left on, slow work done during the request, and OPcache disabled. Measure with Debugbar or Pulse first to see which applies.

Does php artisan optimize make Laravel faster?

Yes, it removes work from every request by caching config, routes, views and events. The gain per request is modest compared with fixing a slow query, but it is free, so run it on every production deploy.

How do I find slow queries in Laravel?

Use Debugbar or Telescope locally, Pulse in production, and the MySQL slow query log for everything else. A DB::listen() callback that logs queries above a threshold is a simple option when you cannot install tools. Then run explain() on each slow query to see whether it uses an index.

Is Laravel Octane worth it?

For high-traffic APIs where each request does little database work, often yes. For a typical business app where response time is dominated by queries, fix the database and caching first, because Octane will not make a slow query faster. Octane also requires a VPS or container and code that is safe in a long-lived process.

Should I use Redis or the file cache in Laravel?

Use Redis in production once you have real traffic or more than one server, for cache, sessions and queues. The file driver is fine for local development and small single-server sites.

If you have worked through this Laravel performance checklist and want a second pair of eyes on a slow app, I offer Laravel performance optimization that starts with measurement and ends with a before-and-after report, and I can keep it fast afterwards as part of ongoing PHP and Laravel maintenance.