In one sentence

The N+1 problem is when your code runs one query for a list and then one extra query per item; eager loading with with() fetches all the related data up front in a single extra query.

πŸ›’Imagine buying ingredients for 50 recipes by driving to the shop, buying one item, driving home, and repeating 50 times. Eager loading is writing one shopping list and making a single trip.

Why it matters

The #1 Laravel performance bug

🐌

It hides in innocent code

{{ $post->author->name }} looks harmless, but in a loop it fires a query every single time.

πŸ“ˆ

It grows with your data

Fast with 10 test posts, painfully slow with 1,000 real ones. The bug appears in production, not on your laptop.

πŸͺ„

The fix is one word

Adding with('author') usually turns hundreds of queries into two.

The big picture

Counting the queries

Say the blog home page lists 50 posts and shows each post's author. Here's what reaches the database:

Lazy loading (N+1) 1 + 50 = 51 queries posts 1 query for posts, then 50 tiny queries: SELECT * FROM users WHERE id = ? (once per post) Eager loading with('author') 2 queries posts users 1 query for posts, then 1 query: SELECT * FROM users WHERE id IN (3, 7, 12, ...) With 1,000 posts: lazy = 1,001 queries, eager = still 2.
Each outlined bar is a separate round trip to the database. Eager loading collapses all of them into one WHERE IN query.
Lazy loading
Eloquent's default: a relationship is queried the first time you access it, one model at a time.
Eager loading
Telling Eloquent in advance which relationships you'll need, so it loads them for the whole collection at once.
N+1
1 query for the list + N queries for the related data, where N is the number of items.
Step by step

Find it, fix it, prevent it

  1. Recognise the N+1 pattern

    The controller fetches posts. The view loops and touches a relationship on each one. Nothing looks wrong.

    
              $posts = Post::latest()->take(50)->get();   // 1 query
              return view('posts.index', compact('posts'));
            
    
              @foreach ($posts as $post)
                  <h2>{{ $post->title }}</h2>
                  <p>by {{ $post->author->name }}</p>   {{-- +1 query each time --}}
              @endforeach
            

    To see it, install Laravel Debugbar (composer require barryvdh/laravel-debugbar --dev) and open the Queries tab, or use Laravel Telescope. Look for the same query repeated many times.

  2. Fix it with with()

    Name the relationships you'll use. You can load several, and nested ones with dot notation.

    
              $posts = Post::with(['author', 'category', 'comments.author'])
                  ->latest()
                  ->take(50)
                  ->get();
            

    That's 1 query for posts + 1 each for authors, categories, comments and comment authors: 5 queries total, no matter how many posts there are.

    To load only some columns, list them (always include the keys): with('author:id,name').

  3. Already have the models? Use load()

    with() goes on the query. load() does the same thing to models or a collection you already fetched, for example a route-model-bound post.

    
              public function show(Post $post)
              {
                  $post->load('comments.author');
                  return view('posts.show', compact('post'));
              }
            

    loadMissing() only loads relationships that aren't loaded yet.

  4. Need just a number? Use withCount()

    Showing β€œ12 comments” doesn't need every comment row. withCount() adds a subquery and gives you a {relation}_count attribute.

    
              $posts = Post::with('author')->withCount('comments')->get();
            
    
              <span>{{ $post->comments_count }} comments</span>
            

    Avoid $post->comments->count() in a loop (loads every comment) and $post->comments()->count() (one query per post). There's also withSum, withAvg, withMax and withExists.

  5. Make lazy loading throw an error in development

    Model::preventLazyLoading() turns every accidental lazy load into an exception, so you catch N+1 bugs the moment you write them.

    
              use Illuminate\Database\Eloquent\Model;
    
              public function boot(): void
              {
                  Model::preventLazyLoading(! $this->app->isProduction());
              }
            

    Passing ! isProduction() keeps production users safe from crashes while developers see the error immediately. Model::shouldBeStrict() turns this on together with the other strict checks.

Common mistakes

Avoid this, do that

❌ Avoid

          $posts = Post::all();
          // view uses $post->author and $post->category
        

With 200 posts this runs 401 queries.

βœ… Do

          $posts = Post::with(['author', 'category'])->get();
        

3 queries, whether you have 200 posts or 20,000.

❌ Avoid

          {{ $category->posts->count() }} posts
        

Loads every post into memory just to count them.

βœ… Do

          Category::withCount('posts')->get();
          // then: {{ $category->posts_count }}
        

The database does the counting, in one query.

πŸ’‘ Pro tip: always eager load on a model

If a relationship is needed almost every time, add protected $with = ['author']; to the model. Use it sparingly, because it applies to every query on that model.

πŸ‹οΈ Try it yourself
  1. Seed 50 posts with authors using factories. Render a list showing each author's name.
  2. Install Debugbar and note the query count. Then add with('author') and compare.
  3. Show a comment count per post with withCount('comments').
  4. Enable preventLazyLoading, remove the with(), and read the exception message.
Recap

Key takeaways

Check yourself

Mini quiz