Laravel N+1 query problem

This One Laravel Mistake Was Causing 3-Second API Responses

Laravel N+1 query problem

I’ll never forget the day our mobile team lead walked into my office and said, “Your API is killing our app. Users are seeing spinners for 3-4 seconds on every screen.”

I was defensive at first. “No way. Our API responses are fast. Check the server logs—most endpoints return in under 200ms.”

He showed me his phone. He wasn’t wrong. The app was painfully slow. After two hours of debugging with Chrome DevTools, New Relic, and a lot of coffee, I found the culprit. It wasn’t a slow database query, wasn’t a memory leak and wasn’t even our infrastructure.

It was one line of code that I—and probably you—have written a hundred times without thinking twice.

$users = User::all();

Let me explain how this innocent-looking line brought our API to its knees, and more importantly, how we fixed it without rewriting anything.

The Problem: Eloquent’s Eager Loading (Or Lack Thereof)

Our API endpoint looked something like this:

public function getPosts()
{
    $posts = Post::where('status', 'published')
                 ->orderBy('created_at', 'desc')
                 ->limit(20)
                 ->get();
    
    return response()->json([
        'posts' => $posts
    ]);
}

Simple, right? We’re fetching 20 posts and returning them. On paper, this should be fast.

But here’s what was actually happening behind the scenes. Our Post model had relationships defined:

class Post extends Model
{
    public function user()
    {
        return $this->belongsTo(User::class);
    }
    
    public function comments()
    {
        return $this->hasMany(Comment::class);
    }
    
    public function tags()
    {
        return $this->belongsToMany(Tag::class);
    }
}

And our API response was serializing these relationships automatically:

class Post extends Model
{
    protected $with = ['user', 'comments', 'tags'];
}

That single line—protected $with—meant that every time we loaded posts, Laravel was automatically loading users, comments, and tags. For 20 posts, this created:

  • 1 query for posts
  • 20 queries for users (one per post)
  • 20 queries for comments (one per post)
  • 20 queries for tags (one per post)

That’s 61 database queries for a “simple” API endpoint.

Each query took about 50ms on average. 61 × 50ms = 3,050ms. Three seconds. Just for database queries.

The Lightbulb Moment

I enabled Laravel’s query log and watched in horror:

// In a middleware or service provider during debugging
DB::listen(function($query) {
    Log::info($query->sql);
    Log::info($query->bindings);
    Log::info($query->time);
});

The logs were filling up faster than I could read them:

SELECT * FROM posts WHERE status = 'published' ORDER BY created_at DESC LIMIT 20
SELECT * FROM users WHERE id = 1
SELECT * FROM users WHERE id = 2
SELECT * FROM users WHERE id = 3
... (17 more)
SELECT * FROM comments WHERE post_id = 1
SELECT * FROM comments WHERE post_id = 2
... (18 more)
SELECT * FROM tags INNER JOIN post_tag ON tags.id = post_tag.tag_id WHERE post_tag.post_id = 1
SELECT * FROM tags INNER JOIN post_tag ON tags.id = post_tag.tag_id WHERE post_tag.post_id = 2
... (18 more)

This is the classic N+1 query problem, and I had been writing code that caused it for months.

The Fix: Proper Eager Loading

The solution was embarrassingly simple. Instead of:

$posts = Post::where('status', 'published')
             ->orderBy('created_at', 'desc')
             ->limit(20)
             ->get();

We did this:

$posts = Post::with(['user', 'comments', 'tags'])
             ->where('status', 'published')
             ->orderBy('created_at', 'desc')
             ->limit(20)
             ->get();

That single with() method changed everything. Now Laravel was making:

  • 1 query for posts
  • 1 query for all users (WHERE id IN (1,2,3…))
  • 1 query for all comments (WHERE post_id IN (1,2,3…))
  • 1 query for all tags with pivot data

4 queries total instead of 61.

Our API response time dropped from 3 seconds to 280ms. The mobile team stopped complaining. Crisis averted.

But this was just the beginning. Once I knew what to look for, I found this problem everywhere in our codebase.

Other Places This Was Hiding

1. API Resources With Nested Relationships

We were using API Resources to format our responses:

class PostResource extends JsonResource
{
    public function toArray($request)
    {
        return [
            'id' => $this->id,
            'title' => $this->title,
            'author' => [
                'name' => $this->user->name,  // N+1 here!
                'avatar' => $this->user->avatar_url,
            ],
            'comments_count' => $this->comments->count(),  // Another N+1!
            'tags' => $this->tags->pluck('name'),  // And another!
        ];
    }
}

Every single property that accessed a relationship was triggering a separate query. For a list of 20 posts, this was causing 60+ queries.

The fix:

// In the controller
$posts = Post::with(['user', 'comments', 'tags'])
             ->where('status', 'published')
             ->limit(20)
             ->get();

return PostResource::collection($posts);

Better yet, we added a scope to the Post model:

class Post extends Model
{
    public function scopeWithApiRelations($query)
    {
        return $query->with(['user', 'comments', 'tags']);
    }
}

// Usage
$posts = Post::withApiRelations()
             ->where('status', 'published')
             ->limit(20)
             ->get();

Now we couldn’t forget to eager load.

2. Counting Relationships

This one hurt:

class PostResource extends JsonResource
{
    public function toArray($request)
    {
        return [
            'id' => $this->id,
            'title' => $this->title,
            'comments_count' => $this->comments->count(),  // Loads ALL comments!
        ];
    }
}

Every time we accessed $this->comments->count(), Laravel was loading every single comment into memory just to count them. For posts with hundreds of comments, this was insane.

The fix:

// Use withCount instead
$posts = Post::withCount('comments')
             ->where('status', 'published')
             ->limit(20)
             ->get();

// Now in the resource
class PostResource extends JsonResource
{
    public function toArray($request)
    {
        return [
            'id' => $this->id,
            'title' => $this->title,
            'comments_count' => $this->comments_count,  // Uses the COUNT query
        ];
    }
}

This changed the query from:

SELECT * FROM comments WHERE post_id = 1  -- Loads everything
SELECT * FROM comments WHERE post_id = 2  -- Loads everything
-- ... etc

To:

SELECT post_id, COUNT(*) as comments_count 
FROM comments 
WHERE post_id IN (1,2,3...) 
GROUP BY post_id

One efficient query instead of 20 queries that load full records.

3. Conditional Eager Loading

Sometimes we only needed relationships under certain conditions:

public function getPost($id, Request $request)
{
    $query = Post::query();
    
    // Always eager load user
    $query->with('user');
    
    // Only load comments if requested
    if ($request->input('include_comments')) {
        $query->with('comments.user');  // Nested eager loading!
    }
    
    // Only load tags if requested
    if ($request->input('include_tags')) {
        $query->with('tags');
    }
    
    $post = $query->findOrFail($id);
    
    return new PostResource($post);
}

This gave clients control over what data they needed, reducing unnecessary queries.

4. The “Load” Method for Already-Fetched Models

Sometimes we fetched models first and then realized we needed relationships:

$post = Post::find($id);

// Later in the code...
if ($someCondition) {
    $post->load('comments');  // Lazy eager loading!
}

The load() method lets you eager load relationships on models you’ve already retrieved, avoiding N+1 problems later in your code.

The Global Scope Approach

After fixing dozens of endpoints individually, I realized we needed a systemic solution. We created global scopes for our most common use cases:

class Post extends Model
{
    protected static function booted()
    {
        static::addGlobalScope('api', function ($builder) {
            if (app()->runningInConsole()) {
                return;  // Don't apply in CLI
            }
            
            $builder->with(['user:id,name,avatar_url']);
        });
    }
}

This ensured that every time we fetched posts through the API, we automatically eager loaded the user relationship. We made it selective—only loading the columns we needed.

The Debugging Tools That Saved Us

1. Laravel Debugbar

We installed Laravel Debugbar in our development environment:

composer require barryvdh/laravel-debugbar --dev

This showed us exactly how many queries each page/endpoint was making. It was eye-opening and embarrassing.

2. Laravel Telescope

For production debugging, we used Telescope:

composer require laravel/telescope
php artisan telescope:install
php artisan migrate

Telescope’s query monitoring helped us identify slow endpoints and see exactly which queries were causing problems.

3. Custom Query Counter Middleware

We built a simple middleware to alert us when endpoints were making too many queries:

class QueryCountMiddleware
{
    public function handle($request, Closure $next)
    {
        $queryCount = 0;
        
        DB::listen(function () use (&$queryCount) {
            $queryCount++;
        });
        
        $response = $next($request);
        
        if ($queryCount > 10) {
            Log::warning('High query count', [
                'url' => $request->url(),
                'queries' => $queryCount
            ]);
        }
        
        return $response;
    }
}

Any endpoint making more than 10 queries got logged for review.

The Select Statement Optimization

Even with proper eager loading, we were still loading too much data:

// Bad: Loads all columns from posts and users
$posts = Post::with('user')->get();

Our user table had 30+ columns, including bio, preferences, settings, etc. We didn’t need most of that.

The fix:

// Good: Only load what we need
$posts = Post::select('id', 'title', 'content', 'user_id', 'created_at')
             ->with('user:id,name,avatar_url')
             ->get();

This reduced our data transfer by 60% and made serialization faster.

The Pagination Mistake

We had this code:

$posts = Post::with('user')->get();
return $posts->paginate(20);

Seems fine, right? Wrong. get() loads ALL posts into memory, then paginate() slices them. For 10,000 posts, we were loading 10,000 records, serializing them, and then only showing 20.

The fix:

$posts = Post::with('user')->paginate(20);

Call paginate() directly on the query builder. Now we only fetch 20 records from the database.

The Results

After three weeks of systematically fixing these issues across our API:

  • Average API response time: 2,800ms → 320ms (88% improvement)
  • Database query count per request: 145 avg → 8 avg (94% reduction)
  • Database CPU usage: 68% → 22% (68% reduction)
  • Mobile app load times: 4-5 seconds → under 1 second
  • Support tickets about “slow app”: 23/week → 2/week

But here’s the real win: our mobile team stopped scheduling “performance sprint” weeks. Users stopped leaving 1-star reviews complaining about speed. Our app store rating went from 3.2 to 4.6.

What I Learned

1. Eloquent’s magic comes at a cost: Those automatic relationships are convenient but dangerous if you’re not careful.

2. Monitor everything: You can’t optimize what you don’t measure. Install Debugbar and Telescope today.

3. N+1 queries are everywhere: They hide in plain sight. A simple $model->relationship can cause dozens of queries.

4. Eager loading is not automatic: Just because you define a relationship doesn’t mean Laravel will load it efficiently.

5. Select only what you need: Loading entire models when you need 3 columns is wasteful.

Quick Wins You Can Implement Today

Here’s your action plan:

Step 1: Install Laravel Debugbar

composer require barryvdh/laravel-debugbar --dev

Step 2: Add this to your base controller:

protected function eagerLoadRelations($query, array $relations)
{
    return $query->with($relations);
}

Step 3: Review your API Resources and add proper eager loading to controllers:

// Find this pattern
$this->relationship->property

// Add eager loading
$model->with('relationship')

Step 4: Use withCount() for counting relationships:

// Instead of
$model->comments->count()

// Use
$model->withCount('comments')
// Then access
$model->comments_count

Step 5: Add query monitoring middleware to catch regressions.

The Bottom Line

This one mistake—not properly eager loading relationships—was costing us users, revenue, and our sanity. The fix was simple once we knew what to look for.

If your Laravel API is slower than it should be, I’d bet money you have N+1 queries. Install Debugbar right now. You’ll be shocked at what you find.

And when you fix it, your users will notice. Our mobile app reviews went from “slow and buggy” to “fast and reliable” in a single deployment.


Leave a Comment

Comments

No comments yet. Why don’t you start the discussion?

Leave a Reply

Your email address will not be published. Required fields are marked *