Laravel automatic eager loading: kill N+1 with one line

Hidden gem: one line in AppServiceProvider turns every forgotten with() into one batched query. 51 queries became 2 in our Laravel 13.37 test.

For your AI agent

Copies a prompt. Paste it into the AI agent you already have open.

Read https://laravel-vuejs.space/laravel-automatic-eager-loading.md
Hidden gem: one line in AppServiceProvider turns every forgotten with() into one batched query. 51 queries became 2 in our Laravel 13.37 test.
Show the diff. Do not commit.
On this page · 10 sections
  1. What automatic eager loading is and why people miss it
  2. The usual way vs the gem
  3. Real-world example: an Inertia posts page
  4. It plays nicely with preventLazyLoading
  5. Gotchas
  6. Try it yourself
  7. How useful is this?
  8. Recap
  9. FAQ
  10. Next steps
  11. Sources

You shipped the posts page with with('author'). Two sprints later someone adds the comment count to the Vue card, forgets the with(), and the debug bar shows 51 queries again. Laravel has a one-line switch that catches that for you, and most apps have never turned it on.

TL;DR

  • Call Model::automaticallyEagerLoadRelationships() in AppServiceProvider::boot(). When you touch a relation that is not loaded on one model, Laravel loads it for every model in the same collection with one where in (...) query.
  • It also works down the chain: users, then posts, then comments ran as 3 queries instead of 61 in our test.
  • Prefer a per-query opt-in? Call ->withRelationshipAutoloading() on one Eloquent collection.
  • It is not a replacement for thinking: relation methods like $post->comments()->count() and a fresh query inside a loop still run once per row.

What you'll learn

  • What Model::automaticallyEagerLoadRelationships() does
  • Classic with() vs automatic eager loading side by side
  • How it behaves with Inertia props, API resources and preventLazyLoading
  • Gotchas: bigger payloads, relation methods, child-to-parent, queries in loops

Who it's for: Laravel 12–13 developers, including Inertia + Vue apps

Tested with: Laravel 13.37 · PHP 8.4 · SQLite 3.46 · inertia-laravel 3.5

What automatic eager loading is and why people miss it#

Automatic eager loading shipped in Laravel 12.8 (April 2025, PR #53655). Laravel 12.9 added withRelationshipAutoloading() on single models and fixed a closure serialization error with queued models. The Laravel 12 docs labelled the feature beta. The 13.x docs no longer carry that warning.

It sits between “Lazy Eager Loading” and “Preventing Lazy Loading” in a very long relationships page. If you learned Eloquent before 2025, you already “know” eager loading and never scroll back.

app/Providers/AppServiceProvider.phpphp
use Illuminate\Database\Eloquent\Model;

public function boot(): void
{
    Model::automaticallyEagerLoadRelationships();
}

That is the whole setup. Every Eloquent collection a query returns now carries a small callback. The first time you read a missing relation on any model in it, Laravel runs loadMissing() for the whole collection instead of lazy loading one row.

Automatic eager loading flow: Post::all() runs one query and returns 50 posts in a collection. You read $post->author on any one post. If the relation is already loaded, Eloquent returns it with no query. If it is not loaded and automatic eager loading is on, Eloquent loads author for the whole collection with one where in query. If it is off, it lazy loads one query per post, which is the N+1.
One missing relation on one model triggers one batched load for its whole collection.

The usual way vs the gem#

The classic fix is to list every relation the page reads in with(). It works, until the page and the with() list drift apart.

with() you have to remember, then automatic eager loading:

app/Http/Controllers/PostController.phpphp
public function index(): Response
{
    $posts = Post::query()
        ->with(['author', 'comments']) // must match what the page reads
        ->latest('id')
        ->paginate(10);

    return Inertia::render('Posts/Index', [
        'posts' => $posts->through(fn (Post $post) => [
            'id' => $post->id,
            'title' => $post->title,
            'author' => $post->author->name,
            'comments' => $post->comments->count(),
        ]),
    ]);
}
app/Http/Controllers/PostController.phpphp
public function index(): Response
{
    $posts = Post::query()
        ->latest('id')
        ->paginate(10);

    return Inertia::render('Posts/Index', [
        'posts' => $posts->through(fn (Post $post) => [
            'id' => $post->id,
            'title' => $post->title,
            'author' => $post->author->name,
            'comments' => $post->comments->count(),
        ]),
    ]);
}

We ran both in a scratch Laravel 13.37 app with 10 authors, 50 posts and 200 comments, counting queries with DB::listen:

CodeLazy (default)with()Automatic
Post::all() then $post->author->name5122
Users → posts → comments (docs example)613 if you list both3
Same, plus $comment->author (4 levels)2614 if you list all three4
paginate(10) + author + comments (Inertia page above)2244

The automatic version produced the exact same SQL as a hand-written with(): select * from "authors" where "authors"."id" in (1, 2, …). It just decides at read time instead of query time.

Tip: Not ready to flip it globally?

Turn it on for one result set: Post::all()->withRelationshipAutoloading(). For a paginator, call it on the page’s collection: $posts->getCollection()->withRelationshipAutoloading(). Both gave us 2 and 3 queries with the global switch off.

Real-world example: an Inertia posts page#

The “automatic eager loading” controller above is the real one from the lab. Here is the Vue page that reads those props. Nothing changes on the front end, which is the point: the backend stops caring which fields the card grows next sprint.

resources/js/pages/Posts/Index.vuevue
<script setup lang="ts">
defineProps<{
  posts: {
    data: { id: number; title: string; author: string; comments: number }[]
  }
}>()
</script>

<template>
  <ul>
    <li v-for="post in posts.data" :key="post.id">
      {{ post.title }} · {{ post.author }} · {{ post.comments }} comments
    </li>
  </ul>
</template>

We hit that route through the HTTP kernel with an X-Inertia header. The page ran four queries on the posts database: the paginator count, the page of posts, one authors query, one comments query.

API resources work the same way. A PostResource that reads $this->author->name, wrapped in PostResource::collection(Post::all()), ran 2 queries instead of 51.

It plays nicely with preventLazyLoading#

A lot of apps run Model::preventLazyLoading(! app()->isProduction()) (or Model::shouldBeStrict()) to make N+1 loud in development. Turn on automatic eager loading as well and the autoloader runs first:

SettingsPost::all() + $post->author
preventLazyLoading() onlyLazyLoadingViolationException
preventLazyLoading() + automatic2 queries, no exception

That is good news for production and a trade-off for development. Your strict mode stops telling you where with() is missing, because nothing is missing any more. If you want the warnings for a while, keep strict mode alone in local and enable automatic loading only in production:

app/Providers/AppServiceProvider.phpphp
public function boot(): void
{
    Model::preventLazyLoading(! $this->app->isProduction());
    Model::automaticallyEagerLoadRelationships($this->app->isProduction());
}

Gotchas#

Touch it once, load it for everyone. We read $posts->first()->author for a page heading. Laravel loaded the author for all 50 posts, because that is the whole idea. On a 5,000-row export where you only need one author, that is 4,999 rows you did not ask for.

Gotcha: Bigger Inertia payloads

A loaded relation is serialized. In the lab we passed a raw collection of posts as an Inertia prop after reading $posts->first()->author once. Every post in the JSON now carried a full author object. If you pass models straight to Inertia, map them to arrays or use an API resource so the prop only ships the fields you chose.

Relation methods are not covered. The autoloader lives on the dynamic property. $post->comments()->count() in a loop still ran 51 queries. $post->comments->count() ran 2, but it loads every comment just to count them. For counts, withCount('comments') is still the right tool: 1 query.

A new query per row is still N+1. Each query returns its own collection. Post::where('id', $id)->get() inside a foreach gets no help, because each collection only has one model in it.

Child to parent costs one extra query. Reading $comment->post while looping a post’s comments ran one more posts where id in (...) query, for posts you already had in memory. chaperone() on the hasMany hydrates the parent for free (2 queries instead of 3). The two features stack fine.

Single models gain nothing. Post::find(1)->author still runs 2 queries, the same as lazy loading. Post::create() followed by ->author lazy loads as usual. The win only appears when there is a collection to batch.

Limited relations stay correct. A hasMany(...)->latest('id')->limit(2) relation auto-loaded with a row_number() over (partition by ...) window query, so each post still got its own 2 latest comments, not 2 in total.

Factories switch it off while making models, so your seeders and tests behave the way they did before (PR #56211).

Try it yourself#

Count the queries in tinker

bash
php artisan tinker
php
use App\Models\User;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\DB;

DB::enableQueryLog();
User::all()->each(fn ($user) => $user->posts->count());
count(DB::getQueryLog()); // 1 + one per user

Model::automaticallyEagerLoadRelationships();
DB::flushQueryLog();
User::all()->each(fn ($user) => $user->posts->count());
count(DB::getQueryLog()); // 2

Swap User and posts for any model and hasMany you already have.

How useful is this?#

Score
Time saved per use★★★★★ One line in a service provider, no hunting with() lists
How often you’ll reach for it★★★★★ Every list page, resource collection and export
Risk if misused★★☆☆☆ Extra rows loaded and bigger JSON if you pass raw models
Overall4.5 / 5: the cheapest N+1 insurance Laravel ships

If your app has a list page and a debug bar, try it today and compare the query count.

Recap#

  • Model::automaticallyEagerLoadRelationships() makes a missing relation load once for the whole collection, at any depth.
  • ->withRelationshipAutoloading() gives you the same thing for one collection or model.
  • It runs before preventLazyLoading(), so strict mode stops throwing for collection models.
  • Relation methods, per-row queries and single models get no help. Use withCount, batch your queries, and chaperone() for child-to-parent.
  • Shipped in Laravel 12.8; tested here on Laravel 13.37.

FAQ#

Does automatic eager loading replace with()?

It makes a forgotten with() cheap, and it runs the same SQL. Keep explicit with() where you want the query shape visible in the controller, or where you constrain the eager load with a closure.

Which Laravel version do I need?

Laravel 12.8 or newer for Model::automaticallyEagerLoadRelationships(), and 12.9 or newer for withRelationshipAutoloading() on a single model. We tested on Laravel 13.37.

Will it break preventLazyLoading or shouldBeStrict?

Nothing breaks, but the autoloader runs before the lazy loading check, so collection models stop throwing LazyLoadingViolationException. Keep strict mode alone in development if you still want those warnings.

Is it safe for Inertia props?

Yes, as long as you map models to arrays or API resources. A raw model collection serializes every relation that got loaded, including ones you only touched once.

Does it work with paginate()?

Yes. The page of results is an Eloquent collection, so paginate(10) plus author and comments ran 4 queries: count, page, authors, comments.

Next steps#

Sources#

Found this useful?

Ask questions, share what you built.

Join thousands of Laravel and Vue developers in our Facebook group, or get new posts in your Telegram feed. Sharing this article helps too.

Keep reading

Newsletter

Get the best of Laravel & Vue, once a month.

One short email with new guides, release notes worth reading and hand-picked community links. No spam, unsubscribe any time.