All articles
Lukas Mateffy·Apr 10, 2026·5 min read
THE PROBLEM

N+1 queries silently kill performance

You fetch 100 records. Then loop through them. Each iteration triggers another query. 1 + 100 = 101 queries instead of 2.

47xqueries executed vs needed
php
$posts = Post::all();
foreach ($posts as $post) {
    echo $post->user->name;
}

N+1 queries are the most common performance issue in Laravel applications. They happen when you fetch a collection of records, then access a relationship on each one. Laravel's Eloquent makes this feel seamless—you just write $post->user—but behind the scenes, it's executing a separate database query for every single iteration.

The pattern is so common because it doesn't show up in development. With 10 records in your local database, those extra queries barely register. But in production, with thousands of records and concurrent users, those 1001 queries become a database meltdown.

What N+1 looks like in practice

WITHOUT eager loading
47queries2.3s
SELECT * FROM posts
SELECT * FROM users WHERE id = 1
SELECT * FROM users WHERE id = 2
... 44 more identical queries
WITH eager loading
2queries45ms
SELECT * FROM posts
SELECT * FROM users WHERE id IN (...)

The cost adds up fast

Each database query has overhead: connection pool management, query parsing, execution planning, network round-trip, result serialization. When you're doing this 47 times instead of 2, you're not just spending 23x more time—you're also consuming 23x more database connections, leaving less capacity for other requests.

In a production environment with concurrent users, this creates a cascading effect. Slow queries lead to connection pool exhaustion, which leads to request queuing, which leads to timeouts, which leads to frustrated users.

Detect with one command

Flags queries repeating 3+ times with exact source location

bash
$ php artisan perf:query --n1=3
{
  "n1": {
    "candidates": [
      {
        "count": 47,
        "table": "contacts",
        "normalized_sql": "select * from \"contacts\" where \"id\" = ?",
        "example_source": [
          {
            "file": "app/Domains/Deals/Resources/DealResource/Pages/ListDeals.php",
            "line": 47,
            "function": "getTableQuery"
          }
        ]
      }
    ]
  }
}

The --n1=3 flag tells laraperf to flag any SQL pattern that repeats 3 or more times. You can adjust this threshold—--n1=2 catches more potential issues, --n1=5 focuses on only the most egregious cases.

The output includes the exact file path and line number where the N+1 originates. Not a stack trace buried in vendor code—the actual line in your application where you called $post->user or similar. This precision is what makes automated fixing possible.

The fix is one line

Implicit lazy loading
// ❌ N+1 - queries = 1 + N
$posts = Post::all();
foreach ($posts as $post) {
    $post->user->name; // Query #2, #3, #4...
}
Explicit eager loading
// ✅ Eager loading - queries = 2
$posts = Post::with('user')->get();
foreach ($posts as $post) {
    $post->user->name; // Already loaded!
}

The with() method tells Eloquent to fetch the relationship in the original query using a JOIN or a separate query with WHERE IN. Both are dramatically more efficient than individual queries per record.

For nested relationships, you can eager load multiple levels: Post::with('user.company'). You can also eager load multiple relationships: Post::with(['user', 'comments']). The principle is the same—load all the data you need upfront, not on demand.

What you get

Exact line numbers

File path and line where the N+1 originates in your code, not buried in vendor frames

Query hash

Reference identical queries across different sessions and track them over time

Occurrence count

See exactly how many times each query repeated—no guessing about severity

Table name

Know which table is being queried repeatedly to prioritize fixes

Beyond eager loading

Sometimes eager loading isn't the right solution. If you only need the user name for 2 out of 100 posts, eager loading all 100 users wastes memory. In these cases, consider:

Selective loading: Use when($needsUser, fn($q) => $q->with('user')) to conditionally eager load.

Lazy eager loading: Call $posts->load('user') after filtering to eager load only the records you actually need.

Data transfer objects: If you only need specific fields, use Post::with(['user:id,name']) to limit what gets loaded.

Let your agent fix it automatically

The JSON output includes file:line. Your LLM agent can navigate, read context, and apply eager loading without human intervention.

1Run perf:watch to start capture
2Exercise the app to trigger queries
3Run perf:query --n1=3 to detect issues
4Agent parses JSON and navigates to source file
5Agent applies ::with() fix based on context
6Re-run capture to verify the fix worked

This workflow transforms N+1 detection from a manual debugging chore into an automated maintenance task. The agent doesn't just find the problem—it understands the relationship from your code context, applies the appropriate eager loading syntax, and verifies the query count actually decreased.

You can run this in CI to catch N+1 regressions before they reach production. Add it to your GitHub Actions workflow, and any PR that introduces an N+1 query will fail the build with the exact location and a suggested fix.

Get started

Two ways to install — manual or let your agent handle it.

Manual install

1Install via Composer
composer require mateffy/laraperf --dev
2Using Laravel Boost?

If you have Laravel Boost installed, run the following after installing laraperf — the skill is automatically added.

php artisan boost:update

Let your agent do it

Install the skill permanently with the CLI, or paste a prompt for a one-shot setup.

npx skills add mateffy/laraperf

Or paste this prompt for a quick one-shot:

Using Laravel Boost? Run php artisan boost:update after installing — the skill is added automatically.