Laravel · Laravel

Laravel Query Optimisation — A Field Guide

Practical Eloquent and SQL query optimisation for Laravel apps: fixing N+1 queries with eager loading, indexing what you actually filter on, and knowing when to drop to raw SQL.

John Kihiu12 min read

Most Laravel query performance problems aren't SQL problems, they're Eloquent usage problems. The database engine is fine; the ORM is making it do far more round trips than the page needs. The fix is almost always the same short list: stop lazy-loading in a loop, index what you filter and sort on, and select only the columns you're going to use — in roughly that order of impact.

The N+1 problem is still the biggest win

The single highest-leverage fix in any Laravel codebase is finding the loop that touches a relationship lazily. `$orders->each(fn($o) => $o->customer->name)` looks harmless and generates one query per order instead of one query total. Laravel Debugbar or Telescope will show you the pattern immediately — a request that fires 200 nearly-identical queries. The fix is eager loading with `with()`, but the real fix is making N+1 impossible to ship by accident: enable `Model::preventLazyLoading()` in a non-production environment, which throws instead of silently querying, so the problem surfaces in local dev or CI rather than in a slow production endpoint.

PHP · EAGER LOADING AND SELECTIVE COLUMNS
// N+1: one query per order to fetch its customer
$orders = Order::where('status', 'pending')->get();
foreach ($orders as $order) {
    echo $order->customer->name; // triggers a query, N times
}

// Fixed: eager load, and only pull the columns the view needs
$orders = Order::with(['customer:id,name,email'])
    ->select(['id', 'customer_id', 'total', 'status'])
    ->where('status', 'pending')
    ->get();

// In non-production, make N+1 impossible to ship silently
// AppServiceProvider::boot()
Model::preventLazyLoading(! app()->isProduction());

Index what you actually filter and sort on

Eloquent's query builder can only be as fast as the indexes underneath it. A `WHERE status = ? ORDER BY created_at DESC` on a table with no composite index on `(status, created_at)` will do a full scan or an expensive filesort regardless of how clean the Eloquent code looks. Run `EXPLAIN` on the actual generated SQL — `DB::enableQueryLog()` or Telescope's query tab shows you exactly what Eloquent sent — and check whether the query is using an index or falling back to a table scan. A missing composite index is the most common cause of a query that's fast with 1,000 rows and unusable at 1,000,000.

Composite index column order matters

An index on (status, created_at) serves WHERE status = ? and WHERE status = ? ORDER BY created_at efficiently. The same index in reverse order, (created_at, status), does not — MySQL and Postgres both require the leftmost columns of a composite index to be used for the index to help a filter. Match the column order to your actual WHERE clause, not the order fields appear in the migration.

Chunking and cursors for large datasets

`Model::all()` or an unbounded `get()` on a large table loads every matching row into memory at once — fine for a few thousand rows, a memory exhaustion risk for a few million. `chunk()` and `chunkById()` process results in batches, and `cursor()` uses a PHP generator to keep memory flat regardless of result size, at the cost of holding the underlying database cursor open for the duration. For anything iterating a large table in a queued job — exports, bulk updates, report generation — `cursor()` or `lazy()` (Laravel's cursor-backed lazy collection) should be the default, not `get()`.

chunk() breaks silently if you mutate the ordering column mid-loop

chunk() orders by primary key internally and re-queries the next page based on the last seen ID. If your loop body updates or deletes rows in a way that shifts what the next page would return, rows get skipped or processed twice. Use chunkById() with an explicit column, or switch to cursor() if you need to mutate rows as you iterate.

When to drop to raw SQL

Eloquent's query builder covers the vast majority of cases cleanly, but a handful of things are consistently painful to express through it: window functions, recursive CTEs, complex conditional aggregation, or anything needing a database-specific feature the builder doesn't wrap. Fighting the builder to force it into a shape it wasn't designed for usually produces worse SQL than writing the query directly. `DB::select()` with bound parameters, or a raw expression wrapped in `DB::raw()` for a single clause, is the right tool — Eloquent isn't a purity test, it's a convenience layer, and stepping outside it for the 5% of queries that need it is normal.

Wrapping up

Query optimisation in Laravel is mostly about not fighting the database from inside the ORM: eliminate N+1 queries with eager loading (and enforce it with preventLazyLoading), index columns in the order your WHERE and ORDER BY clauses actually use them, chunk or cursor through large datasets instead of loading everything at once, and don't be afraid to write raw SQL for the handful of queries the builder was never meant to express cleanly.

John Kihiu
Acumatica ERP Developer · Laravel Engineer

Independent software engineer in Nairobi specialising in Acumatica customisations, Laravel backends, and tax fiscalisation integrations across East and Southern Africa.