Laravel Eloquent Performance Cheat Sheet

N+1 queries, chunking and the memory limits Eloquent hides

Eloquent makes the expensive query look identical to the cheap one. These are the patterns that keep a convenient ORM from becoming the bottleneck.

Category: Laravel Difficulty: Intermediate Version: 1.0 Updated: October 9, 2025 Author: Sabir

Loading relations

Call Does
Post::with("author")->get() Eager load — 2 queries instead of N+1
Post::with("comments.author")->get() Nested eager load
Post::with(["author:id,name"])->get() Select only the columns you need
Post::withCount("comments")->get() A count without loading the rows
Post::withExists("comments")->get() Cheaper still when you only need a boolean
$posts->load("tags") Eager load after the fact on an existing collection
Model::preventLazyLoading() Throws on lazy loads in dev — the fastest way to find them all

Iterating large sets

Method Memory Caveat
get() Everything at once Fine until the table grows
chunk(500, fn) One chunk at a time MODIFYING rows inside shifts the offsets
chunkById(500, fn) One chunk at a time The safe choice when you write during the loop
lazy() One model at a time Chunks underneath; behaves like a generator
cursor() One model at a time A single held connection — not for slow per-row work

Finding an N+1 in one run

Call Model::preventLazyLoading(! app()->isProduction()) in a service provider. Every lazy load then throws in local and staging with the exact relation and model named, and stays silent in production. It finds in one page load what reading the query log finds in an afternoon.

Code examples

Find every N+1 in one page load PHP

Throws on any lazy load outside production, naming the exact relation and model.

use Illuminate\Database\Eloquent\Model;

public function boot(): void
{
    // Loud in local and staging, silent in production
    Model::preventLazyLoading(! app()->isProduction());
}

// Then the fix is mechanical:
$posts = Post::query()
    ->with(['author:id,name', 'tags:id,name'])   // only the columns used
    ->withCount('comments')                       // a count, not the rows
    ->latest()
    ->paginate(20);

// Writing while iterating? chunkById, never chunk — chunk() shifts its
// own offsets when the rows it already passed are modified.
Post::where('needs_reindex', true)
    ->chunkById(500, fn ($rows) => $rows->each->reindex());

FAQs

What is the fastest way to find N+1 queries?

Model::preventLazyLoading(! app()->isProduction()) in a service provider. Every lazy load then throws in local and staging, naming the exact model and relation, and stays silent in production.

chunk or chunkById?

chunkById whenever the loop modifies the rows it is reading. Plain chunk() uses offsets, and rows changing under it shift those offsets so records get skipped.