Eloquent's convenience is also its main performance trap: $order->customer->name reads exactly like a property access, which is precisely why it's easy to write inside a loop over 200 orders and not notice you just issued 200 separate queries. Almost every Eloquent performance problem in production traces back to one of a small handful of causes, and all of them are fixable without dropping down to raw SQL.
The N+1 query, and why eager loading fixes it
The N+1 pattern: one query to fetch a list of models, then one additional query per model to fetch a related model, triggered lazily the first time the relationship is accessed. Laravel Debugbar or the query log will show this as a suspicious number of near-identical queries differing only by an ID. The fix is eager loading with with(), which issues one additional query (or a small constant number) for the related models regardless of how many parent rows there are.
// N+1: one query for orders, then one query PER order for customer
$orders = Order::all();
foreach ($orders as $order) {
echo $order->customer->name;
}
// Fixed: two queries total, regardless of order count
$orders = Order::with('customer')->get();
foreach ($orders as $order) {
echo $order->customer->name;
}
Laravel's Model::preventLazyLoading(), set in a service provider's boot() method (typically gated to non-production environments), throws an exception the moment code tries to lazy-load a relationship. Running this in local/CI turns silent N+1s into loud test failures instead of a slow endpoint someone notices weeks later.
Selecting only the columns you actually use
Order::all() pulls every column, including large text or JSON columns you may not render. select() on the query, and with('customer:id,name') for constraining eager-loaded relationship columns, cuts the payload Eloquent has to hydrate into model instances — hydration cost is real for wide tables and large result sets, not just the wire transfer.
$orders = Order::query()
->select(['id', 'reference_number', 'customer_id', 'total', 'created_at'])
->with(['customer:id,name'])
->latest()
->paginate(25);
chunk() and cursor() for large result sets
Model::all() or an unbounded get() over a large table loads every matching row into memory as model instances simultaneously — fine for a few thousand rows, a real problem past a few hundred thousand. chunk() processes results in batches by re-querying with an offset each time, which is safe for most reporting/export jobs. cursor() uses a PHP generator to hydrate one model at a time from a single query, using far less memory than chunk() for read-only iteration, though it keeps one database connection open for the duration.
// chunk(): safe when you're also writing/updating rows as you go
Order::where('status', 'pending')->chunkById(500, function ($orders) {
foreach ($orders as $order) {
$order->recalculateTotal();
$order->save();
}
});
// cursor(): lowest memory, read-only iteration, one query total
foreach (Order::where('status', 'archived')->cursor() as $order) {
$exporter->write($order);
}
When to drop to the query builder instead of Eloquent
Eloquent's model hydration (casting attributes, building relationships, firing model events) has real overhead compared to the plain query builder or raw SQL, which matters for reporting queries that return tens of thousands of rows purely for aggregation. DB::table() returns plain stdClass objects with none of that hydration cost — the right choice for a dashboard aggregate query that will never be an Eloquent model in the rest of the app anyway.
$totals = DB::table('orders')
->selectRaw('DATE(created_at) as day, SUM(total) as total')
->where('created_at', '>=', now()->subDays(90))
->groupBy('day')
->orderBy('day')
->get();
The index is still doing more work than the query builder choice
None of the above helps if the underlying column isn't indexed — eager loading a relationship joined on an unindexed foreign key just moves the same slow table scan from N queries to one. EXPLAIN on the actual generated SQL (visible via Laravel's query log or Debugbar) is the tool that tells you whether the fix is an eager-load, a column selection, or genuinely a missing index — guessing which one applies without looking at the query plan wastes more time than it saves.
Wrapping up
Most Eloquent performance problems are one of four things: an N+1 from missing eager loading, hydrating more columns or rows than the response needs, loading an entire table into memory instead of chunking or cursoring through it, or asking Eloquent to hydrate models for a query that only needed aggregated numbers. Fix them in that order of likelihood, and confirm with the actual query log rather than guessing — a missing index will still make a well-eager-loaded query slow.
Independent software engineer in Nairobi specialising in Acumatica customisations, Laravel backends, and tax fiscalisation integrations across East and Southern Africa.