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.
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
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());
Frequently asked questions
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.
Was this cheat sheet useful?
Comments
No comments yet — be the first.
Need a different cheat sheet?
Tell us what you would like to see and we will build it — free.
Request a cheat sheet