
Eloquent is one of the reasons developers fall in love with Laravel. You define a hasMany or belongsTo, call a method and your data appears. It reads like English. It feels clean. Junior developers pick it up in an afternoon.
It also hides enough complexity to quietly wreck a production database if you are not paying attention.
The problem is not that Eloquent is bad. It is that Eloquent is so good at abstracting the database that developers stop thinking about the database. Every relationship call looks identical in code. The cost difference between a single query and two hundred queries is completely invisible until your page takes six seconds to load and your DBA starts sending you screenshots.
This article is about the specific ways Eloquent relationships silently accumulate queries and what you can do about each one.
The N+1 Problem Is Not a Bug. It Is a Feature You Are Misusing.
Start here because this is where most performance problems begin.
The N+1 problem happens when you load a collection of models and then access a relationship on each one inside a loop. Eloquent fires one query to fetch the collection and then one additional query per model to load the relationship. If you have 200 records, you have 201 queries.
// This looks completely reasonable
$posts = Post::all();
foreach ($posts as $post) {
echo $post->author->name; // A new query fires here for every post
}
What Eloquent is actually doing:
SELECT * FROM posts;
SELECT * FROM users WHERE id = 1;
SELECT * FROM users WHERE id = 2;
SELECT * FROM users WHERE id = 3;
-- ... one per post
The classic fix is eager loading with with(). You tell Eloquent upfront which relationships you need and it fetches them in a second query using WHERE IN, then maps them in memory.
// One query for posts, one query for all authors
$posts = Post::with('author')->get();
foreach ($posts as $post) {
echo $post->author->name; // No additional query
}
Two queries total, regardless of how many posts you have.
Laravel 12.8 Adds Automatic Eager Loading
Laravel 12.8 introduced automaticallyEagerLoadRelationships(), which can detect which relationships you access and batch-load them for you — no with() required.
You can enable it three ways:
Per-query, using withRelationshipAutoloading() on a collection:
$orders = Order::all()->withRelationshipAutoloading();
foreach ($orders as $order) {
// Laravel detects 'client' is accessed and loads it in bulk
echo $order->client->name;
}
Per-model, using the $autoLoadRelations property:
class Order extends Model
{
protected bool $autoLoadRelations = true;
}
Globally, in AppServiceProvider:
public function boot(): void
{
Model::automaticallyEagerLoadRelationships();
}
This feature is still marked as beta and the Laravel team is actively gathering feedback. There are real edge cases to know before you enable it globally:
- Relationships whose query logic depends on instance state (
$this->idinside the relationship method) can break. When Laravel batches them,$thisdoes not resolve correctly per-row. - Model instances retrieved from a Redis cache can throw exceptions when global auto-loading is active.
- It does not replace
with()for cases where you want to constrain the eager load (filter, select specific columns, order). You still needwith()for that.
The per-query withRelationshipAutoloading() approach is the safest starting point. The global setting is convenient for new projects but should be tested carefully on existing codebases.
The N+1 Problem You Cannot See
The dangerous version hides inside API resources or view components where the loop is not visible from the call site.
// Controller looks completely clean
public function index()
{
$posts = Post::paginate(20);
return PostResource::collection($posts);
} // PostResource looks clean too
class PostResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'title' => $this->title,
'author_name' => $this->author->name, // Query per post
'category' => $this->category->name, // Query per post
'tags' => $this->tags->pluck('name'), // Query per post
];
}
}
That controller looks like one query. It is actually 62 queries for a 20-item page. paginate() always fires two queries — one COUNT to determine total pages and one SELECT to fetch the current page. Then author, category and tags each fire once per resource. None of it is visible from the controller.
Fix it at the source:
public function index()
{
$posts = Post::with(['author', 'category', 'tags'])->paginate(20);
return PostResource::collection($posts);
}
Now it is five queries total: the two paginate queries plus one for each eager loaded relationship. The resource methods hit already-loaded in-memory collections for everything else.
Laravel Debugbar or Telescope will show you the exact query count per request. Install one now if you have not.
composer require barryvdh/laravel-debugbar --dev
Eager Loading Too Much Is Also a Problem
The overcorrection from N+1 is loading everything with with() regardless of whether you need it.
// You added all these "just in case"
$users = User::with([
'orders',
'orders.items',
'orders.items.product',
'orders.items.product.category',
'profile',
'addresses',
'subscriptions',
])->get();
Each relationship loads its full result set and maps it to the parent in memory. If users have 50 orders each and each order has 20 items, you are pulling a massive dataset into PHP memory before you touch a single record.
Load only what the current request actually uses. If you are rendering a user list with names and email addresses, do not eager load their orders.
A related issue: when you do eager load, you are pulling every column by default. If your products table has a description column containing 10KB of text, every eager loaded product carries that text into memory even if you only need the product name.
Constrain both the columns and the conditions:
$orders = Order::with([
'items:id,order_id,product_id,quantity,price',
'items.product:id,name,sku',
])->where('status', 'pending')->get();
The relationship:columns syntax tells Laravel to select only those columns in the eager load query. Memory footprint drops significantly on large datasets.
One rule to remember: the primary key and the foreign key must be included in your column list. Eloquent needs them to match related records back to their parents. Leave them out and the relationship returns empty with no error.
Counting With Relationships Is Expensive
A common pattern for building dashboards or list pages:
$users = User::with('orders')->get();
foreach ($users as $user) {
echo $user->orders->count(); // Counting an in-memory PHP collection
}This is wasteful. You loaded every order for every user just to count them. Use withCount() instead:
$users = User::withCount('orders')->get();
foreach ($users as $user) {
echo $user->orders_count; // Available as a property, no extra query
}Eloquent adds a subquery that fetches the count and appends it to each model as an attribute. You get the number without pulling the actual rows.
You can combine this with conditions:
$users = User::withCount([
'orders',
'orders as completed_orders_count' => fn ($q) => $q->where('status', 'completed'),
'orders as pending_orders_count' => fn ($q) => $q->where('status', 'pending'),
])->get();
Now each user has orders_count, completed_orders_count and pending_orders_count as properties. One query. No relationship loading required.
whereHas() Has a Hidden Cost
This method is useful for filtering by relationship existence, but most developers do not know what query it generates.
// Give me all users who have at least one completed order
$users = User::whereHas('orders', function ($q) {
$q->where('status', 'completed');
})->get();
The generated SQL uses a correlated subquery:
SELECT * FROM users
WHERE EXISTS (
SELECT * FROM orders
WHERE orders.user_id = users.id
AND orders.status = 'completed'
)
That EXISTS subquery runs once per row in the outer query. On a large users table with no composite index on orders.user_id and orders.status, this gets slow fast.
Always check your indexes. At minimum you want a composite index on the foreign key and any column you filter on inside whereHas:
// Migration: composite index on the orders table
$table->index(['user_id', 'status']);
For high-volume filtering, a JOIN is often faster than a correlated subquery:
$users = User::query()
->join('orders', function ($join) {
$join->on('orders.user_id', '=', 'users.id')
->where('orders.status', 'completed');
})
->select('users.*')
->distinct()
->get();
JOINs have their own tradeoffs, but on properly indexed columns the execution plan is usually significantly better than a correlated subquery. Run EXPLAIN on both and let the numbers decide.
Lazy Loading Is Enabled by Default and You Should Disable It
Laravel lets you disable lazy loading globally. You should.
// AppServiceProvider::boot()
use Illuminate\Database\Eloquent\Model;
public function boot(): void
{
Model::preventLazyLoading(! app()->isProduction());
}
This throws a LazyLoadingViolationException in local and staging environments whenever a relationship is accessed without being eager loaded. In production it continues lazy loading so your app does not break, but you can customize that to log violations instead:
// AppServiceProvider::boot()
public function boot(): void
{
Model::preventLazyLoading();
Model::handleLazyLoadingViolationUsing(function ($model, $relation) {
$message = "Lazy load of [{$relation}] on model [" . get_class($model) . "].";
if (app()->isProduction()) {
logger()->warning($message);
} else {
throw new \Illuminate\Database\LazyLoadingViolationException($model, $relation);
}
});
} // This will now throw locally
$post = Post::find(1);
echo $post->author->name; // LazyLoadingViolationException
// Fix it
$post = Post::with('author')->find(1);
echo $post->author->name; // Fine
Every N+1 that was silently firing in production becomes a loud exception in development. You fix it before it ships instead of diagnosing it from a slow query log months later.
In tests where you need to access relationships without eager loading, disable it in setUp and restore it in tearDown:
use Illuminate\Database\Eloquent\Model;
protected function setUp(): void
{
parent::setUp();
Model::preventLazyLoading(false);
}
protected function tearDown(): void
{
Model::preventLazyLoading(true);
parent::tearDown();
}
The ->load() Method Can Cause N+1 When Called Inside a Loop
Sometimes you already have a model and need to load a relationship after the fact. ->load() is the right tool for a single model.
$post = Post::find($id);
if ($needsAuthor) {
$post->load('author'); // One query
echo $post->author->name;
}
Where people go wrong is calling ->load() inside a loop over a collection. It fires one query per model — N+1 written differently.
// This is N+1, just written differently
foreach ($posts as $post) {
$post->load('author'); // Query per post
}
If you need to load relationships on a collection you already have in memory, call ->loadMissing() on the collection itself:
// One query for all authors across the entire collection
$posts->loadMissing('author');
loadMissing() fetches the relationship in bulk for the whole collection and skips any models where it is already populated. Calling it twice does not fire duplicate queries.
Chunking Large Datasets Exists for a Reason
When you need to process every row in a large table, ->get() pulls everything into PHP memory at once.
// This will exhaust memory on any table with serious data volume
$users = User::all();
foreach ($users as $user) {
// process
}
Use ->chunk() to process records in batches, keeping memory usage constant:
// Fetches 500 rows at a time. Memory stays flat.
User::chunk(500, function ($users) {
foreach ($users as $user) {
// process
}
});
Or use ->lazy(), which runs chunked queries internally but returns a LazyCollection for a cleaner foreach syntax:
// Chunked queries under the hood. Cleaner syntax than chunk().
User::lazy()->each(function ($user) {
// process
});
Note: lazy() is not a true database cursor. It executes chunked queries exactly like chunk() does under the hood — the difference is syntax. If you want a real server-side cursor that streams one record at a time using a single open database connection, that is ->cursor(). The tradeoff with cursor() is that the connection stays open for the full duration of the loop, which can time out on long operations.
Both chunk() and lazy() share the same OFFSET-based caveat: if you modify rows during iteration in a way that removes them from the original WHERE clause filter, subsequent chunks will skip records because the OFFSET shifts.
// Dangerous: updating 'processed' removes rows from the WHERE clause.
// The OFFSET shifts and you skip roughly half the records.
User::where('processed', false)->chunk(500, function ($users) {
foreach ($users as $user) {
$user->update(['processed' => true]);
}
});
// Same problem with lazy()
User::where('processed', false)->lazy()->each(function ($user) {
$user->update(['processed' => true]);
});
Use ->chunkById() or ->lazyById() instead. Both paginate by primary key (WHERE id > {last_id}) rather than OFFSET, so modified rows do not affect subsequent batches:
// Safe: each batch picks up from the last seen primary key
User::where('processed', false)->chunkById(500, function ($users) {
foreach ($users as $user) {
$user->update(['processed' => true]);
}
});
// Safe with lazyById()
User::where('processed', false)->lazyById()->each(function ($user) {
$user->update(['processed' => true]);
});
The rule is simple: if you are modifying records during iteration, always use the ById variant.
Select Only What You Need
Every ->get() without a ->select() pulls every column in the table. On tables with many columns or large text fields, this is wasted memory and network overhead on every query.
// Pulls all columns including blobs and large text you do not need
$users = User::all();
// Pulls only what this view actually uses
$users = User::select(['id', 'name', 'email', 'created_at'])->get();
This matters most on eager loads where you are joining many related models in memory. Every unnecessary column multiplies across every related record in the collection.
For APIs specifically, specify columns at the query level rather than relying on a resource transformer to discard them. The resource transformer runs after the query. By then, the unnecessary data is already in PHP memory.
The Query Count Is the Number to Watch
All of this comes down to one habit: look at your query count per request. Not just query time, the count.
A page that fires 300 queries at 1ms each still takes 300ms in database round trips alone, before PHP execution time, view rendering or network latency. A page that fires 5 queries at 10ms each takes 50ms at the database layer and is far easier to optimize from there.
Keep a hard internal limit. If a page is firing more than 10–15 queries, treat it as a bug worth investigating before it ships.
Eloquent will not enforce this for you. Laravel Debugbar will show you. Telescope will log it. DB::listen() will let you dump every query mid-request during debugging.
// Temporary query logger — development only
DB::listen(function ($query) {
logger($query->sql, $query->bindings);
});
The relationships are not the problem. The invisible queries are.
The Real Lesson
Eloquent relationships are not slow. They are a precise tool that requires you to be explicit about what data you need and when you need it. When you are explicit, the ORM works beautifully. When you are not, the ORM works anyway and your database pays for it.
Laravel 12 gives you more tools than ever to catch this early with preventLazyLoading(), Debugbar, Telescope, and now automatic eager loading via withRelationshipAutoloading(). The tooling is there. The discipline is yours.
Constrain your eager loads to the columns and conditions you actually use. Use withCount() instead of loading full relationships just to count them. Use chunkById() or lazyById() when modifying records during iteration.
None of this is advanced. All of it is discipline.
The relationships are beautiful. Keep them that way.