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.
| 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 |
| 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 |
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());
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.
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.