An export endpoint took four minutes and used 1.8 GB of memory. It contained no obviously bad code — a query, a loop, some formatting. The problems were structural: it hydrated 200,000 Eloquent models into memory, triggered a relationship load per row, and selected every column of a table with a large text field nobody was exporting.
ORMs are not slow. They make expensive things look identical to cheap ones, and the cost only becomes visible at a scale where the fix is more disruptive.
Hydration is the cost people miss
Every Eloquent model is an object with attributes, casts, relations and dirty tracking. For a hundred rows that is irrelevant. For two hundred thousand it is the dominant cost, and no index will help. When you are reading data to transform rather than to mutate, ask the query builder for arrays or stream with a cursor instead.
// 1.8 GB and four minutes: every row becomes a model, every row loads a relation.
foreach (Inspection::all() as $inspection) {
$rows[] = [$inspection->reference, $inspection->property->address];
}
// Constant memory, one join, no hydration, and only the columns needed.
DB::table('inspections')
->join('properties', 'properties.id', '=', 'inspections.property_id')
->select('inspections.reference', 'properties.address')
->orderBy('inspections.id')
->chunkById(2000, function ($rows) use ($writer) {
foreach ($rows as $row) { $writer->write([$row->reference, $row->address]); }
});
// When you do need models, load only what you touch, and constrain the relation.
Inspection::query()
->select(['id', 'reference', 'property_id', 'status'])
->with(['property:id,address', 'latestNote' => fn ($q) => $q->select('id','inspection_id','body')])
->lazyById(1000); // a generator: one page in memory at a timeUse chunkById rather than chunk. Plain chunk paginates with OFFSET, which the database recomputes from the beginning on every page — and worse, if rows are inserted or deleted while you iterate, the offset shifts and you silently skip records.
N+1, and the fix that makes it worse
Eager loading solves N+1, and then someone eager-loads a relation with fifty thousand rows per parent and replaces a query problem with a memory problem. The useful habit is to load relations with explicit column lists and constraints, and to reach for withCount or a subquery selection when you only need an aggregate rather than the rows themselves.
Turn on strict mode in development so lazy loading throws rather than quietly working. It converts a production performance incident into a local exception during the five minutes you were writing the code.
Indexes and the queries that cannot use them
- Composite index column order must match the query: filter columns first, then the sort column. An index on (status) does not help ORDER BY created_at.
- A function applied to a column disables the index — DATE(created_at) = ? scans, a range comparison on created_at does not.
- Leading wildcards in LIKE cannot use a B-tree index. If you need that, you need a search index, not a bigger database.
- Keyset pagination beats OFFSET past the first few pages. Deep offsets read and discard everything before them.
- Read the EXPLAIN output rather than guessing. Ten minutes with a query plan beats a day of speculative indexes.
The ORM did not make it slow. It made a full table scan, a hydration of 200,000 objects and a query per row look like four lines of readable code.
The practical discipline is to make cost visible during development: log the query count and total time per request in local environments, fail a test when a request exceeds a query threshold, and look at the numbers for the endpoints that will run against the largest tables. Almost every performance incident I have debugged in a Laravel application was visible on the developer's own machine, in the query log, on the day the code was written.