laravelmastery
SnippetsArticlesSeriesNews

Eloquent, properly · Lesson 3 of 3

Eloquent, properly · Lesson 3

Catching N+1 queries

Query it safely and fastEloquentQueries

The eager load of team and lines only helps if a missing one fails where you can see it. Outside production, turn lazy loading into an exception. A page that quietly runs one query per invoice is the bug. The exception names it before a customer has to.

Video

01 Fail the test, not the customer

preventLazyLoading() belongs in a service provider, switched off in production so a missed load degrades to a query instead of a 500. The flag is the inverse of isProduction(), which keeps local, testing and staging strict. Production stays lenient on purpose.

Example.phpPHP
class Example
{
    public function boot(): void
    {
        Model::preventLazyLoading(! app()->isProduction());
    }
}

A feature test that renders the invoice list will now throw if the controller forgot with('lines'). That is the bug you wanted. The exception is LazyLoadingViolationException, and the message names the model and the relation. Add that name to the with() list for the action the test hit. Do not remove a line from the view to make the query count look small if the view still needs the line.

The short version, with the same guard, is the snippet this series keeps pointing at.

Catch N+1 queries before your users do.

When the exception fires, add the relationship to the with() list for that page. Do not disable the guard to make the test green. The guard is the point. A single test that calls preventLazyLoading(false) teaches the next person that the exception is optional. Leave the boot method as the only switch.

02 Let the feature test see the exception

Laravel's HTTP kernel turns an exception into an error response. A test that calls get() and assertOk() fails with a 500, which is a start, but the failure does not say "lines". Call withoutExceptionHandling() and the same request fails as LazyLoadingViolationException. That is the message you can read, and the one a pull request review can recognise.

PHP
it('renders the invoice list without a lazy load', function () {
    $this->withoutExceptionHandling();

    $team = Team::factory()->create();
    $invoice = $team->invoices()->create([
        'number' => 'INV-1',
        'issued_on' => '2026-10-01',
        'status' => 'issued',
    ]);
    $invoice->lines()->create([
        'description' => 'Hosting',
        'amount' => 2400,
    ]);

    $this->actingAs($invoice->team->owner)
        ->get('/invoices')
        ->assertOk();
});

Create the line, and sign in as $invoice->team->owner. A user from User::factory()->create() is not on that team, so the list is empty and the guard never runs. The view has to read lines, or the test has to, before the exception exists. An index that is green and a show page that still lazy-loads is the same rule: the guard fires on the code path that touches the relation. Render the show page in its own test if the show view reads something the index does not.

automaticallyEagerLoadRelationships() will fetch a relation the moment the view touches it, which hides the N+1 and also loads whatever the template happens to mention. It is not a substitute for the guard, and it is not the with() list this series asked you to write. Leave it off. The explicit list stays the place you decide what this response costs.

Production still degrades. A missed relation there is extra queries, not a 500, because the guard is off. The test is what stops that miss shipping. If a staging box should behave like production, point isProduction() at that environment too, and keep the test suite on an environment where the guard is on.

Recap

You can make a forgotten eager load throw outside production, and see that exception in the feature test that renders the page.

  • preventLazyLoading() names the model and the relation.
  • A feature test that renders the list is enough to catch it, once the list has lines to touch.
  • Turning the guard off to make the test green removes the point of the guard.
  • withoutExceptionHandling() lets the test fail with LazyLoadingViolationException instead of a bare 500.

Check yourself · 3 questions

Question 1 of 3The invoice index throws LazyLoadingViolationException for lines. What do you change?

A forgotten load now throws outside production. Next, the index for that list, and the note you must not treat as empty.

← PreviousEager loading on purpose

On this page

  • Fail the test, not the customer
  • Let the feature test see the exception
  • Recap
  • Check yourself

Eloquent, properly

  • 1 Overview

Shape the data

  • 2 Models that match the domain
  • 3 Migrations you can still read next year
  • 4 Casts, enums and custom casters

Connect it

  • 5 One to many, without the surprises
  • 6 Belongs-to and whereBelongsTo
  • 7 Many to many and pivot data
  • 8 Has many through

Query it safely and fast

  • 9 Local and global scopes
  • 10 Eager loading on purpose
  • 11 Catching N+1 queries
  • 12 Indexes, select and chunking
  • 13 API resources without leaking attributes

Eloquent, properly

Lessons

  1. 01Overview

Shape the data

  1. 02Models that match the domain
  2. 03Migrations you can still read next year
  3. 04Casts, enums and custom casters

Connect it

  1. 05One to many, without the surprises
  2. 06Belongs-to and whereBelongsTo
  3. 07Many to many and pivot data
  4. 08Has many through

Query it safely and fast

  1. 09Local and global scopes
  2. 10Eager loading on purpose
  3. 11Catching N+1 queries
  4. 12Indexes, select and chunking
  5. 13API resources without leaking attributes

Get the Friday email

One snippet and the week’s Laravel news, in your inbox every Friday morning. Unsubscribe whenever you like.

laravelmastery

Practical Laravel learning for working developers, made in the UK.

Learn

  • Snippets
  • Articles
  • Series

Stay current

  • News
  • Friday email
  • RSS feed

Company

  • About
  • Privacy
  • Terms
© 2026 Laravel Mastery. Independent and not affiliated with Laravel Holdings Inc.Designed and built by Half Shell Studios

We remember the videos you watch, where you left off, and which series lessons you mark as read, against this connection’s address. Nothing is stored until you agree.