You ship a Laravel feature on Tuesday. By Friday a customer says the dashboard "feels sluggish." You open the slow query log and find a single endpoint firing 247 queries per request, mostly tiny SELECT * FROM addresses WHERE id = ? calls. The Eloquent code that generated them is six lines long and reads like English.

That's Eloquent in a sentence. It's the friendliest ORM in PHP, the reason Laravel feels productive, and — exactly when you start caring about performance — the layer you need to see through. This isn't a takedown. Eloquent is genuinely good at the things it's good at. The point is to know which 80% of your queries belong in Eloquent, which 15% belong in the Query Builder, and which 5% should be handwritten SQL nobody apologizes for.

What Eloquent Is Actually Good At

Eloquent shines when the work is "fetch a row, modify it, save it" or "fetch a small graph and render it." Models give you typed casts, accessors, mutators, scoped queries, route binding, and serialization to JSON for free. The cost is one method call instead of fifteen lines of PDO.

PHP
$post = Post::where('slug', $slug)
    ->with(['author:id,name', 'tags:id,name'])
    ->firstOrFail();

$post->update(['published_at' => now()]);

That's three lines doing real work — a join-shaped fetch with selected columns, a 404 if missing, an update with timestamps automatic. Writing the equivalent in raw SQL plus mapping plus update plus exception handling is a half-screen of code you'd rather not own.

The problem isn't Eloquent. The problem is treating Eloquent as if it had no SQL underneath. Every method on the chain is generating something, and the generation rules aren't always obvious.

The N+1 Query Is The House Special

If Laravel had a default failure mode, this would be it. The setup is innocent:

PHP
$posts = Post::latest()->take(50)->get();

return view('feed', [
    'rows' => $posts->map(fn ($p) => [
        'title'  => $p->title,
        'author' => $p->author->name,   // 1 query per post
        'tags'   => $p->tags->pluck('name'), // 1 query per post
    ]),
]);

That's one query for the posts plus 50 for the authors plus 50 for the tags. 101 round trips, every single one of them a WHERE id = ? on a primary key. The fix is with():

PHP
$posts = Post::with(['author:id,name', 'tags:id,name'])
    ->latest()->take(50)->get();

Three queries total. The eager-loaded version is the one you wanted; the lazy version is the one you got because nothing in the syntax warned you. The defense is to make lazy loading impossible in development:

PHP
// app/Providers/AppServiceProvider.php
public function boot(): void
{
    Model::shouldBeStrict(! $this->app->isProduction());
}

Model::shouldBeStrict() enables three guards in one call: preventLazyLoading(), preventSilentlyDiscardingAttributes(), and preventAccessingMissingAttributes(). In dev, an accidental $post->author on a model that wasn't eager-loaded throws a LazyLoadingViolationException instead of silently firing a query. You find every N+1 the moment you write it, not the moment a customer notices.

Diagram contrasting two queries against the same posts/authors/tags schema. The naive version fires 1 + 50 + 50 sequential SELECTs in a stair-step timeline. The eager-loaded version fires 3 batched SELECTs side by side. A third panel shows the Laravel call site for each — same code shape, with()/without() being the only difference.
Same data, very different number of round trips — Model::shouldBeStrict() makes the bad version fail loudly in dev

See The SQL Or Stay Confused

You cannot reason about performance without seeing the queries. Three tools cover almost every case:

DB::listen() — log every query the request fires:

PHP
DB::listen(function ($query) {
    Log::debug($query->sql, [
        'bindings' => $query->bindings,
        'time_ms'  => $query->time,
        'connection' => $query->connectionName,
    ]);
});

Drop that into a development service provider and your laravel.log becomes a query timeline. When something feels slow, search the log for the request and count rows.

toSql() and toRawSql() — inspect a builder without running it:

PHP
$builder = Post::where('status', 'published')
    ->whereHas('author', fn ($q) => $q->where('country', 'UA'));

logger($builder->toSql());      // ? placeholders
logger($builder->toRawSql());   // bindings inlined (Laravel 10.15+)

toRawSql() (added in Laravel 10.15) gives you a paste-into-pgAdmin string with bindings already substituted. That's the function I miss most when I have to debug an older codebase.

Laravel Debugbar in dev and Laravel Pulse in production round it out. Pulse's "Slow Queries" card surfaces the top N offenders without you having to instrument anything — install it, and within a day you'll know which endpoints are firing too many queries.

Three side-by-side panels showing the same Eloquent expression at three stages. Left "01 Local code" panel — a clean line like User::with('orders.items')->get() that reads like English in your editor. Middle "02 Production weight" panel — the actual SQL the ORM emits under load: a base SELECT plus a WHERE id IN (...) for orders, plus another for items, totaling three queries that turn into 2,114 when relations weren't eager-loaded. Right "03 Monitor signal" panel — Pulse's Slow Queries card showing the N+1 query at 22 seconds total, Telescope flagging the same endpoint, and a Sentry breadcrumb tying the slow request back to the line of code from panel one.
The same expression as you write it, as it runs at scale, and as the dashboard finally shows it back.

When To Drop To The Query Builder

Eloquent is built on the Query Builder. The Query Builder is built on PDO. You can step down a layer at any time, and you should when the cost of hydrating models outweighs the convenience.

A reporting endpoint that aggregates millions of rows doesn't need 40k Order objects with relations:

PHP
$revenue = DB::table('orders')
    ->selectRaw('date_trunc(\'day\', created_at) as day, SUM(total_cents) as cents')
    ->where('created_at', '>=', now()->subDays(30))
    ->where('status', 'paid')
    ->groupBy('day')
    ->orderBy('day')
    ->get();

That returns plain stdClass rows. No model boot, no casting, no event firing. For a chart endpoint hit hourly, the difference shows up directly in your p95.

The Query Builder also lets you reach for DB::raw() and DB::expression() when you need a CASE, a COALESCE, or a window function:

PHP
DB::table('subscriptions')
    ->select([
        'team_id',
        DB::raw('COUNT(*) as total'),
        DB::raw("COUNT(*) FILTER (WHERE status = 'active') as active"),
    ])
    ->groupBy('team_id')
    ->get();

Don't be embarrassed about it. Half the slow Laravel reports I've fixed were Eloquent collections doing in PHP what one Postgres FILTER clause could do in the database.

Streaming Big Result Sets Without Eating Memory

Loading a million rows into a Collection is how OOM crashes happen. Three options, ordered by how I reach for them:

PHP
// chunkById: stable across deletes, indexed pagination
Order::where('status', 'pending')
    ->chunkById(500, function ($orders) {
        $orders->each->process();
    });

// lazy() / lazyById(): generator-based, single-row hydration
Order::where('status', 'pending')
    ->lazyById(500)
    ->each(fn ($order) => $order->process());

// cursor(): streams via PDO, lowest overhead, no model events fire on writes
foreach (Order::where('status', 'pending')->cursor() as $order) {
    $order->process();
}

chunkById is safest — it paginates by primary key, so deletes inside the loop don't shift the window. lazyById is the modern syntactic sugar for the same idea. cursor() is the fastest but it returns a single row at a time from PDO and is more memory-efficient than the others; it's right when you're streaming a large export and don't need batching semantics.

Raw SQL Is Not An Admission Of Defeat

There are queries Eloquent's grammar can't express cleanly. Window functions, recursive CTEs, full-text search with tsvector, complex aggregations with rollup. When you hit one, drop to DB::select() and write the SQL:

PHP
$top = DB::select(<<<'SQL'
    SELECT
        u.id,
        u.name,
        ROW_NUMBER() OVER (PARTITION BY u.team_id ORDER BY SUM(o.total_cents) DESC) AS rank,
        SUM(o.total_cents) AS revenue_cents
    FROM users u
    JOIN orders o ON o.user_id = u.id
    WHERE o.status = 'paid'
      AND o.created_at >= ?
    GROUP BY u.id, u.name, u.team_id
SQL, [now()->subDays(30)]);

That returns an array of stdClass. Map it to a DTO, return it from your endpoint, and move on. The point of Eloquent is the 80% of CRUD it does well — not pretending the other 20% isn't there.

DB::statement() for DDL or non-SELECT work (a VACUUM, a REINDEX, a CREATE INDEX CONCURRENTLY) is the same idea. You're not betraying Laravel by using its escape hatches; you're using Laravel correctly.

Indexes Are A Schema Concern, Not A Query Concern

The single fastest performance win in most Laravel apps isn't a code change — it's a CREATE INDEX. Eloquent will happily generate a query plan that does a sequential scan over a million rows because nothing told you it would.

A few habits worth building:

  1. EXPLAIN ANALYZE your slow endpoints. In Postgres, prefix the query with EXPLAIN (ANALYZE, BUFFERS) and read the output. A "Seq Scan on orders" on a column you filter every request is a missing index.
  2. Composite indexes match the column order in the WHERE. (team_id, status, created_at) works for "this team's active orders, newest first." (status, team_id) does not.
  3. Index foreign keys. Laravel's foreignId() does this for you when you remember to chain ->constrained(). Older migrations often don't.
  4. whereJsonContains needs a GIN index on Postgres or a generated column index on MySQL — without it, you're scanning every row and parsing JSON in the database.

The migration to add an index is two lines. The slow query without it is two months of "the dashboard is laggy" tickets.

A Working Rule For Picking The Tool

If the question is "fetch and update a row or a small graph," use Eloquent. The N+1 guards plus with() cover almost every case.

If the question is "aggregate, group, or report on a lot of rows," use the Query Builder. Skip the model overhead.

If the question is "expressive SQL — windows, CTEs, full-text, conditional aggregates," write SQL. Use a heredoc, parameterize the bindings, and don't apologize.

The mistake isn't picking the wrong tool — it's picking one tool for everything. Eloquent for reports gets slow; raw SQL for CRUD gets unmaintainable. The senior move is knowing which problem you're solving before you start typing.

A One-Sentence Mental Model

Eloquent is a productivity layer that sits on top of the Query Builder which sits on top of PDO — keep it for the 80% it does well, log every query in development, drop to the layer below the moment hydration cost or expressiveness becomes the bottleneck, and never let with() be the difference between a fast feature and an N+1 incident.