9 min read

Laravel Query Optimization: From 3 Seconds to 30ms

Real-world case study on Laravel optimization techniques that reduced database query time from 3000ms to 30ms using eager loading, indexes, and strict mode.

Laravel Query Optimization: From 3 Seconds to 30ms

Last month, I inherited a Laravel project where the dashboard was taking 3 full seconds to load. Three seconds might not sound terrible, but when users are staring at a loading spinner while your server burns through resources, it's a nightmare. And the client was paying for it.

Here's the thing. Laravel makes database queries incredibly easy to write. So easy that you can accidentally create performance disasters without realizing it. After spending an afternoon with Laravel Debugbar open, I discovered the dashboard was executing 847 database queries to display a simple list of 50 users with their posts and comments.

That's when I knew exactly what I was dealing with: the classic N+1 query problem, combined with missing indexes and some genuinely questionable Eloquent usage.

By the end of that day, I'd reduced those 847 queries down to just 3, and the page load time dropped from 3 seconds to 30 milliseconds. Let me show you exactly how I did it, so you can avoid the same mistakes I see in almost every Laravel project I work on.

Understanding the N+1 Query Problem

Before we get into solutions, you need to understand why this happens. The N+1 problem is sneaky because your code looks clean and works perfectly in development with 10 test records. Then you deploy to production with 10,000 records, and everything grinds to a halt.

Here's what was happening in that dashboard:

// The problematic code that looked innocent
public function index()
{
    $users = User::all(); // 1 query to get users
    
    return view('dashboard', compact('users'));
}

And in the Blade view:

@foreach($users as $user)
    <div>
        <h3>{{ $user->name }}</h3>
        <p>Posts: {{ $user->posts->count() }}</p> <!-- 1 query per user -->
        <p>Comments: {{ $user->comments->count() }}</p> <!-- 1 query per user -->
    </div>
@endforeach

See the problem? For 50 users, this executes:

  • 1 query to fetch users
  • 50 queries to fetch posts for each user
  • 50 queries to fetch comments for each user

That's 101 queries just for this simple example. Now imagine adding categories, tags, roles, and other relationships. You can see how I ended up with 847 queries.

The term "N+1" comes from this pattern: 1 initial query + N additional queries (where N is the number of records). It's the most common performance killer I encounter in Laravel applications, and I've seen it in probably 80% of the projects I've worked on.

Step 1: Enable Strict Mode (Catch N+1 Before It Ships)

Before fixing anything, let's make sure you never ship N+1 queries again. Laravel has a built-in safety net that most developers don't enable. Add this to your AppServiceProvider:

use Illuminate\Database\Eloquent\Model;

public function boot(): void
{
    Model::shouldBeStrict(!$this->app->isProduction());
}

This one line does three things: it prevents lazy loading (throws an exception instead of silently running extra queries), prevents silently discarding unfillable attributes, and prevents accessing missing attributes. In development, you'll get an immediate error like this:

Attempted to lazy load [posts] on model [App\Models\User] but lazy loading is disabled.

No more discovering N+1 problems in production. You'll catch them the moment you write the code.

But what about production? You don't want your app crashing for users. Here's what I do instead:

public function boot(): void
{
    Model::shouldBeStrict();

    if ($this->app->isProduction()) {
        Model::handleLazyLoadingViolationUsing(function ($model, $relation) {
            logger()->warning("Lazy loading [{$relation}] on [" . get_class($model) . "]");
        });
    }
}

Strict everywhere, but in production it logs the violation instead of throwing. You get the safety net without the downtime risk. I check these logs weekly and fix whatever shows up.

Step 2: Eager Loading (Your First Line of Defense)

Laravel's solution to N+1 queries is eager loading. Instead of loading relationships as you access them (lazy loading), you tell Laravel upfront which relationships you'll need.

Here's how I fixed the dashboard:

public function index()
{
    $users = User::with(['posts', 'comments'])->get();
    
    return view('dashboard', compact('users'));
}

That's it. Three queries instead of 101:

  1. SELECT * FROM users
  2. SELECT * FROM posts WHERE user_id IN (1,2,3...)
  3. SELECT * FROM comments WHERE user_id IN (1,2,3...)

The performance difference was immediate. Load time dropped from 850ms to 120ms just with this change. But I wasn't done yet.

Nested Eager Loading

Things get trickier when you have nested relationships. In my case, each post had categories, and I needed to display those too:

// This would still cause N+1 queries for categories
$users = User::with(['posts', 'comments'])->get();
@foreach($user->posts as $post)
    Category: {{ $post->category->name }} <!-- N+1 query for each post -->
@endforeach

The solution? Nested eager loading with dot notation:

$users = User::with([
    'posts.category', // Load posts AND their categories
    'comments.post'   // Load comments AND their related posts
])->get();

You can nest as deep as you need: 'posts.category.parent.owner' all works perfectly. I wish I'd learned this earlier in my Laravel journey.

Conditional Eager Loading with withWhereHas

Sometimes you don't need all related records. Maybe you only want published posts or recent comments. The traditional approach uses constrained eager loading:

$users = User::with([
    'posts' => function ($query) {
        $query->where('status', 'published')
              ->orderBy('created_at', 'desc')
              ->limit(5);
    },
    'comments' => function ($query) {
        $query->where('created_at', '>', now()->subDays(30));
    }
])->get();

But there's a cleaner approach when you also want to filter the parent model. Instead of chaining whereHas() and with() separately (which runs two nearly identical subqueries), use withWhereHas():

// Before: two separate subqueries for the same relationship
$users = User::whereHas('posts', fn ($q) => $q->where('status', 'published'))
    ->with(['posts' => fn ($q) => $q->where('status', 'published')])
    ->get();

// After: one method, one subquery
$users = User::withWhereHas('posts', fn ($q) => $q->where('status', 'published'))
    ->get();

Same result, less duplication, better performance. Small win, but these add up across a large codebase.

Step 3: Database Indexes (The Performance Multiplier)

Eager loading got me from 850ms to 120ms. But I needed to go faster. That's when I started looking at database indexes.

Here's something that surprised me early on: Eloquent doesn't automatically create indexes for your foreign keys (unless you're using foreignId()->constrained(), which adds a foreign key constraint but still benefits from an explicit index). All those user_id, post_id, and category_id columns? Potentially unindexed.

Let me show you what that means in practice. Without indexes, finding all posts for a user requires MySQL to scan every single row in the posts table:

-- Without index: scans all 100,000 posts
SELECT * FROM posts WHERE user_id = 1;
-- Execution time: 450ms

With an index, MySQL can jump directly to the relevant rows:

-- With index: uses index to find rows instantly
SELECT * FROM posts WHERE user_id = 1;
-- Execution time: 8ms

That's a 56x speed improvement from a single line in a migration. If you want a deeper look at indexing strategies, I wrote a complete guide to database indexing in Laravel that covers composite indexes, covering indexes, and common mistakes in detail.

Adding Indexes to Existing Columns

Here's how I added indexes to fix the slow queries:

php artisan make:migration add_indexes_to_posts_table

public function up()
{
    Schema::table('posts', function (Blueprint $table) {
        $table->index('user_id');
        $table->index('category_id');
        $table->index('status');
        $table->index('created_at');
        
        // Composite index for common query patterns
        $table->index(['user_id', 'status', 'created_at']);
    });
}

public function down()
{
    Schema::table('posts', function (Blueprint $table) {
        $table->dropIndex(['user_id']);
        $table->dropIndex(['category_id']);
        $table->dropIndex(['status']);
        $table->dropIndex(['created_at']);
        $table->dropIndex(['user_id', 'status', 'created_at']);
    });
}

Pro tip: When creating new tables, add indexes immediately:

Schema::create('posts', function (Blueprint $table) {
    $table->id();
    $table->foreignId('user_id')->index()->constrained();
    $table->foreignId('category_id')->index()->constrained();
    $table->string('status')->index();
    $table->timestamps();
});

After adding these indexes, my query time dropped from 120ms to 45ms. Getting close to that 30ms target.

Composite Indexes for Complex Queries

Single-column indexes are great, but composite indexes are where you really optimize. If you frequently query by multiple columns together, a composite index can be dramatically faster than multiple single-column indexes.

In my dashboard, I had this query running constantly:

Post::where('user_id', $userId)
    ->where('status', 'published')
    ->orderBy('created_at', 'desc')
    ->get();

Instead of three separate indexes, one composite index handles it:

$table->index(['user_id', 'status', 'created_at']);

The order matters. MySQL uses composite indexes left-to-right, so this index helps with queries filtering by: just user_id, or user_id + status, or all three columns together. But it won't help with queries filtering by just status or just created_at. Keep this in mind when designing your indexes.

Step 4: Optimizing Query Structure

Even with eager loading and indexes, some queries can still be slow. Here are the techniques I used to squeeze out the last bit of performance.

Select Only What You Need

By default, User::all() selects every column. If you only need the name and email, why pull the entire record?

// Instead of this (pulls all columns)
$users = User::with('posts')->get();

// Do this (only pulls what you need)
$users = User::select(['id', 'name', 'email'])
    ->with(['posts' => function ($query) {
        $query->select(['id', 'user_id', 'title', 'created_at']);
    }])
    ->get();

This reduced my payload size by 60% and shaved off another 10ms. It might not seem like much, but when you're serving thousands of requests per hour, it adds up. I use this same approach when building API responses where payload size directly affects latency.

Important: Always include the id column and any foreign key columns (like user_id) in your select. Without them, eager loading won't be able to match relationships.

Use Chunking for Large Datasets

If you need to process thousands of records, don't load them all at once. Laravel's chunk() method processes records in batches, keeping memory usage low:

// Bad: loads 50,000 users into memory
User::all()->each(function ($user) {
    $this->processUser($user);
});

// Good: processes 100 users at a time
User::chunk(100, function ($users) {
    foreach ($users as $user) {
        $this->processUser($user);
    }
});

For even better memory efficiency, lazy() and cursor() process one record at a time:

// Best for memory: processes one user at a time
foreach (User::cursor() as $user) {
    $this->processUser($user);
}

I use chunking constantly for background jobs and data exports. It's the difference between your script running smoothly and your server running out of memory.

Counting Relationships Without Loading Them

Here's a mistake I made for years: loading entire relationships just to count them.

// Inefficient: loads all posts just to count them
$user->posts->count();

// Efficient: counts at the database level
$user->posts()->count();

Notice the difference? $user->posts (without parentheses) loads all posts into memory. $user->posts() (with parentheses) returns a query builder, letting you count at the database level.

Even better, use withCount() when eager loading:

$users = User::withCount(['posts', 'comments'])->get();

// Now you can access counts without additional queries
$user->posts_count; // No query needed
$user->comments_count; // No query needed

This was huge for my dashboard. Instead of loading thousands of posts just to display counts, I got the numbers directly from the database in the same query.

Use simplePaginate Instead of paginate

One more trick that's often overlooked. Laravel's paginate() runs an extra COUNT(*) query to calculate total pages. On large tables, that count can be surprisingly expensive.

// Runs 2 queries: one for data, one for COUNT(*)
$posts = Post::paginate(20);

// Runs 1 query: just the data (shows "Previous/Next" instead of page numbers)
$posts = Post::simplePaginate(20);

If you don't need page numbers (and most modern UIs don't), simplePaginate() cuts your query count in half. I use it anywhere the table has more than 100k rows.

Step 5: Identify Slow Queries

You can't fix what you can't measure. Here are the tools I use to find performance issues before they become problems.

Laravel Debugbar

This is the first package I install on any Laravel project:

composer require barryvdh/laravel-debugbar --dev

Debugbar shows you every query executed, their execution time, and where they're coming from in your code. The "Queries" tab was what helped me discover those 847 queries on the dashboard. Without it, I'd have had no idea.

Automatic Slow Query Logging

For production, I set up automatic logging in AppServiceProvider so I don't need to actively monitor:

use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;

public function boot(): void
{
    DB::listen(function ($query) {
        if ($query->time > 500) {
            Log::warning('Slow query detected', [
                'sql'      => $query->sql,
                'bindings' => $query->bindings,
                'time_ms'  => $query->time,
            ]);
        }
    });
}

I check these logs weekly. Any query over 500ms gets investigated. You'd be surprised how often a missing index is the culprit.

The explain() Method

Want to see how MySQL executes your query? Use explain():

User::where('email', '[email protected]')->explain()->dd();

This shows you MySQL's execution plan, including which indexes it's using (or not using). If you see type: ALL in the output, that's a full table scan. You need an index.

The Results: From 3 Seconds to 30ms

After applying all these techniques, here's what the final dashboard code looked like:

public function index()
{
    $users = User::select(['id', 'name', 'email', 'created_at'])
        ->with([
            'posts' => function ($query) {
                $query->select(['id', 'user_id', 'title', 'status', 'created_at'])
                      ->where('status', 'published')
                      ->latest()
                      ->limit(5);
            },
            'posts.category:id,name'
        ])
        ->withCount([
            'posts',
            'comments' => function ($query) {
                $query->where('created_at', '>', now()->subDays(30));
            }
        ])
        ->latest()
        ->simplePaginate(50);
    
    return view('dashboard', compact('users'));
}

The results:

  • Query count: 847 → 3 queries
  • Page load time: 3000ms → 30ms
  • Memory usage: 45MB → 8MB
  • Database CPU: 78% → 4%

That's a 100x performance improvement. The client was thrilled. Users stopped complaining. And these are techniques I now apply to every project from day one.

When to Optimize (and When Not To)

Here's something important: don't optimize prematurely. I've wasted hours optimizing queries that ran once a day on 50 records. Not worth it.

Optimize when page load times exceed 300ms, you see 10+ queries for a single page, your database CPU is consistently high, users are complaining about speed, or you're working with 1000+ records.

Don't optimize when everything feels fast, you're working with tiny datasets, it's a rarely-used admin page, or the code complexity would increase significantly.

Remember: readable code that's "fast enough" beats unreadable code that's 10ms faster. I optimize when it matters, not just because I can. For a broader set of Laravel performance wins beyond just queries, I covered caching, route optimization, and asset tricks in a separate post.

Quick Reference: The Optimization Checklist

Here's the exact order I follow when debugging a slow Laravel page:

  1. Enable Model::shouldBeStrict() to catch N+1 problems automatically
  2. Install Debugbar and count queries on the slow page
  3. Add eager loading with with() for every relationship accessed in loops
  4. Add indexes to foreign keys and frequently-queried columns
  5. Select only needed columns with select() on both parent and eager-loaded queries
  6. Use withCount() instead of loading relationships just to count them
  7. Switch to simplePaginate() on large tables
  8. Set up slow query logging in production to catch regressions

Follow this order and you'll solve 90% of Laravel performance issues before lunch.

Frequently Asked Questions

How do I find N+1 queries in an existing Laravel project?

Install Laravel Debugbar and visit every major page in your app. The Queries tab shows the exact number of queries per page load. Anything over 10-15 queries for a single page is suspicious. You can also add Model::preventLazyLoading() in your AppServiceProvider to get instant exceptions whenever an N+1 occurs during development.

Should I use preventLazyLoading in production?

Not as an exception thrower. Use Model::handleLazyLoadingViolationUsing() in production to log violations instead of throwing. This way your app keeps working for users while you collect data on which relationships need eager loading. I review these logs weekly and fix whatever shows up.

Does eager loading always improve performance?

Not always. If you're loading a relationship you never actually use, eager loading wastes a query. And for single-record lookups (like a user profile page), lazy loading one relationship is fine since it's just one extra query. Eager loading really shines when you're iterating over collections and accessing relationships inside loops.

How many indexes should I add to a table?

There's no magic number, but here's my rule: index every foreign key column and every column you use in WHERE, ORDER BY, or GROUP BY clauses frequently. But don't index everything. Each index speeds up reads but slows down writes (inserts and updates). For write-heavy tables like logs or analytics events, be conservative with indexes.

What's the difference between cursor() and chunk()?

chunk() loads N records at a time into memory, processes them, then loads the next batch. cursor() uses a PHP generator to process one record at a time, so only one Eloquent model exists in memory at once. Use cursor() when memory is your constraint. Use chunk() when you need to process batches together (like bulk inserts or API calls).

What's Next?

Query optimization is just one piece of the performance puzzle. Once you've nailed down your queries, there's more ground to cover:

Query caching with Redis can eliminate database hits entirely for frequently-accessed data. If your dashboard shows the same data to every user, why run the query 10,000 times a day?

Background processing with queues moves heavy work off the request cycle. Instead of making users wait for report generation or email sending, push it to a queue and respond instantly.

API response caching at the HTTP layer means your database never even knows the request happened. For read-heavy applications, this is the single biggest performance win you can make.

But master these fundamentals first. Eager loading, indexes, strict mode, and smart query structure will solve 90% of your Laravel performance issues.

If you're working on a Laravel project that feels slow, start with Debugbar. Find your N+1 queries. Add your indexes. Enable strict mode. You'll be amazed how much faster things get.

Need help optimizing your Laravel application's performance? I've helped dozens of clients go from multi-second page loads to sub-100ms responses. Let's talk about your project.

Share: X/Twitter | LinkedIn | | RSS
Hafiz Riaz

About Hafiz

Senior Full Stack Developer. I build production software with Laravel, Filament, Vue, and AI integrations, and write about the real decisions behind shipping it.

Get in touch →

Get web development tips via email

Join 50+ developers • No spam • Unsubscribe anytime