You write the feature. It looks clean. You test it locally on a handful of records, it responds in milliseconds, and you ship it. Then production traffic arrives, and your database starts screaming.
Here’s the code in question:
$users = User::all();
foreach ($users as $user) {
echo $user->posts->count();
}
On your local machine with five test users, this is instant. In production with 1,000 users, it silently executes 1,001 database queries. Your application slows to a crawl. Users wait. Servers buckle.
This is the N+1 query problem — one of the most common and most overlooked performance issues in Laravel applications. It’s almost invisible until it bites you, because nothing in the code looks wrong. The ORM is doing exactly what you told it to. The problem is what you didn’t tell it.
Understanding how to identify and fix N+1 problems — and developing the instinct to write efficient Eloquent code from the start — begins with understanding how your ORM actually works beneath the surface.
What Is an ORM?
ORM stands for Object-Relational Mapping. It bridges the gap between the object-oriented world of your application code and the relational world of your database.
Without an ORM, every database interaction is raw SQL:
SELECT * FROM users WHERE id = 1;
SELECT * FROM posts WHERE user_id = 1;
Raw SQL is powerful and precise, but it requires you to parse result arrays, manually map columns to variables, handle SQL injection risks, and re-implement the same boilerplate across hundreds of queries.
An ORM translates between the two worlds. You work with objects; the ORM handles the SQL:
$user = User::find(1);
echo $user->name;
foreach ($user->posts as $post) {
echo $post->title;
}
The Conceptual Mapping
Database Table → Model Class
Database Row → Model Instance (Object)
Table Column → Object Property
Foreign Key → Relationship Method
Result Set → Collection of Models
Advantages of Using an ORM
- Abstraction: Write PHP instead of SQL; switch databases with minimal changes
- Security: Parameterized queries baked in — SQL injection is dramatically harder to introduce
- Productivity: Common operations (find, create, update, delete) are one-liners
- Relationships: Express related data as object relationships, not manual JOINs
- Ecosystem: Query scopes, casts, observers, events, and more
The Trade-offs
ORMs can generate inefficient SQL if you don’t understand what they’re doing, load far more data than you need, and abstract away details that matter for performance. The discipline required is not less than raw SQL — it’s different. Knowing your ORM’s behavior is how you use it well.
Eloquent: Laravel’s ORM
Laravel ships with Eloquent, an expressive ORM built on the Active Record pattern. Each model represents a database table; model instances represent individual rows. Eloquent infers conventions (table name from class name, primary key as id, timestamps) so minimal configuration is required:
class User extends Model
{
// Maps to the `users` table by convention.
// Eloquent assumes: primary key = `id`, timestamps = true.
}
Understanding Eloquent Relationships
Relationships are where Eloquent gets both powerful and dangerous.
hasMany
A user has many posts. The posts table has a user_id foreign key pointing back to the user.
class User extends Model
{
public function posts(): HasMany
{
return $this->hasMany(Post::class);
}
}
belongsTo
The inverse: a post belongs to a user.
class Post extends Model
{
public function author(): BelongsTo
{
return $this->belongsTo(User::class, 'user_id');
}
}
hasOne
class User extends Model
{
public function profile(): HasOne
{
return $this->hasOne(Profile::class);
}
}
belongsToMany
Posts and tags share a many-to-many relationship via a pivot table.
class Post extends Model
{
public function tags(): BelongsToMany
{
return $this->belongsToMany(Tag::class);
}
}
The Critical Question
Defining a relationship method creates a query builder — but doesn’t run the query yet. When you write:
$posts = $user->posts;
You’re accessing the relationship as a property (no parentheses). This triggers Eloquent’s magic __get() method, which detects the relationship method, runs the query, caches the result on the model, and returns it.
This deferred behavior — queries only running when accessed — is Lazy Loading. And it is both Eloquent’s most convenient feature and the root of the N+1 problem.
What Is Lazy Loading?
Lazy Loading means relationship queries are deferred until the moment you actually access the relationship.
$user = User::find(1);
// Only one query has executed:
// SELECT * FROM users WHERE id = 1 LIMIT 1
$posts = $user->posts;
// NOW the second query runs:
// SELECT * FROM posts WHERE user_id = 1
$count = $user->posts->count();
// No additional query — result is cached on $user after the first access
Approximate SQL generated:
SELECT * FROM users WHERE id = 1 LIMIT 1;
SELECT * FROM posts WHERE user_id = 1;
After the first access, Eloquent caches the result on the model instance. Subsequent accesses to $user->posts return the cached collection — no additional query.
When Lazy Loading Is the Right Tool
Lazy loading is perfectly appropriate in several situations:
- Single model instances: Loading one user and conditionally accessing their posts
- Conditional access: Only some code paths need the related data
- Unknown access patterns: A service method that may or may not use the relationship
// Fine: single user, posts only loaded if the condition is met
$user = User::find(1);
if ($user->is_active) {
displayPosts($user->posts); // Query runs here, once
} else {
showInactiveMessage(); // No query at all
}
When Lazy Loading Becomes Dangerous
The moment you loop over a collection of models and access a relationship on each one, lazy loading fires one query per model. That’s N+1.
What Is Eager Loading?
Eager Loading instructs Eloquent to load relationships upfront, as part of the initial query. Laravel’s with() method enables this:
$users = User::with('posts')->get();
Instead of waiting to see whether you’ll access posts, Eloquent fires two queries immediately:
Approximate SQL generated:
-- Query 1: retrieve all users
SELECT * FROM users;
-- Query 2: retrieve ALL their posts in one round-trip
SELECT * FROM posts WHERE user_id IN (1, 2, 3, 4, 5, ...);
Eloquent collects user IDs from the first result, bundles them into a single WHERE user_id IN (...) clause, then distributes posts back to their matching users in PHP memory.
When your loop accesses $user->posts, there’s nothing to query — the data is already in memory:
$users = User::with('posts')->get();
foreach ($users as $user) {
foreach ($user->posts as $post) {
// No database query — $user->posts is already loaded
echo $post->title;
}
}
Two queries total — regardless of whether you have 10 users or 10,000.
The key shift: With eager loading, the loop iterates over in-memory data. With lazy loading inside a loop, the loop drives database queries. That single distinction explains everything.
Lazy Loading vs Eager Loading
| Dimension | Lazy Loading | Eager Loading |
|---|---|---|
| When query runs | On first property access | At query time, before access |
| Query count (collection) | 1 + N (one per model) | 1 + 1 (flat, regardless of N) |
| Syntax | $user->posts |
User::with('posts')->get() |
| Performance in loops | Degrades linearly with N | Constant — N doesn’t matter |
| Memory usage | Lower if relationship unused | Higher upfront; all data loaded |
| Best for | Single model, conditional access | Collections with known related data |
| N+1 risk | High (inside loops) | None for the eager-loaded relation |
| Wasted data risk | Low — only loads on access | Higher — loads even if unused |
Neither strategy is universally superior. The right choice depends on your access pattern.
The N+1 Query Problem
This is the section that matters most.
The Problem, Made Explicit
$users = User::all(); // Query 1: SELECT * FROM users
foreach ($users as $user) {
foreach ($user->posts as $post) {
// Query per user: SELECT * FROM posts WHERE user_id = ?
// This fires ONCE for each $user in the outer loop
echo $post->title;
}
}
Each time PHP evaluates $user->posts inside the loop, Eloquent checks: “Have I loaded this relationship?” The answer is no — each $user is a fresh model instance with no cached relationship data. So Eloquent fires a query. One per user. Every iteration.
What the database actually sees:
-- The first query
SELECT * FROM users;
-- Then one query per user...
SELECT * FROM posts WHERE user_id = 1;
SELECT * FROM posts WHERE user_id = 2;
SELECT * FROM posts WHERE user_id = 3;
-- ...and so on, for every user in the table
The math:
1 query → retrieve users
N queries → retrieve posts for each user
Total = N + 1 queries
With 100 users: 101 queries
With 1,000 users: 1,001 queries
With eager loading: 2 queries, always
Why This Destroys Performance
Each database query carries overhead that doesn’t disappear because the query is simple:
- Network round-trip: Each query travels from PHP to the database server and back
- Connection overhead: Each query requires parsing, planning, and execution
- Concurrency: Under load, multiple requests each doing 101 queries compete for database connections — a pool that handles 100 concurrent simple-query requests can collapse under 100 concurrent 101-query requests
A single WHERE user_id IN (1, 2, ... 100) query returns the same data as 100 individual queries — in a fraction of the time, using one round-trip, one parse, one plan.
The invisible danger: N+1 problems are silent in development. With 3 test users, you’re running 4 queries instead of 2 — the difference is imperceptible. In production with 500 users per page, you’re running 501 queries instead of 2. The first time you notice is when the monitoring alerts fire.
Solving N+1 with Eager Loading
The fix is one method call:
// Before (N+1 problem):
$users = User::all();
// After (eager loading):
$users = User::with('posts')->get();
The loop code is identical — but Eloquent now has all the posts in memory before the loop begins:
$users = User::with('posts')->get();
foreach ($users as $user) {
foreach ($user->posts as $post) {
// No database query — data already loaded
echo $post->title;
}
}
What Eloquent does internally:
- Executes
SELECT * FROM usersand collects all user IDs - Executes
SELECT * FROM posts WHERE user_id IN (1, 2, 3, ...) - Groups the posts by
user_idin PHP memory - Sets the grouped posts as the cached relationship on each
Usermodel
Eager Loading an Already-Fetched Collection
If you’ve already retrieved a collection without eager loading, loadMissing() loads relationships afterward without re-querying models that already have the data:
$users = User::all(); // Already fetched
$users->loadMissing('posts'); // Two queries — no extra work for already-loaded models
Nested Relationships
Real applications have deeper relationship trees. Accessing nested relationships lazily creates nested N+1 problems.
// PROBLEM: 1 + N + N*M queries
$users = User::all();
foreach ($users as $user) {
foreach ($user->posts as $post) { // N queries
foreach ($post->comments as $comment) { // N*M queries
echo $comment->body;
}
}
}
With 100 users and 10 posts each: 1 + 100 + 1,000 = 1,101 queries.
Eager loading handles nested relationships with dot notation:
// Three queries total — flat, regardless of depth
$users = User::with('posts.comments')->get();
You can eager load multiple relationships, including mixes of flat and nested:
$users = User::with([
'posts.comments',
'posts.tags',
'profile',
])->get();
Eloquent is smart enough to load posts only once even when both posts.comments and posts.tags are specified.
Conditional Eager Loading
Pass a closure to constrain the eager-loaded query:
$users = User::with([
'posts' => function ($query) {
$query->where('published', true)
->orderBy('created_at', 'desc');
},
])->get();
Modern Laravel supports arrow function syntax:
$users = User::with([
'posts' => fn($query) => $query->where('published', true),
])->get();
Important: This constraint applies only to the eager-loaded result. If you later access a user’s unpublished posts, they won’t be in the already-loaded collection. This is expected behavior — but know that the constraint affects what’s in memory, not what’s in the database.
withCount — Aggregates Without Loading
A common pattern is needing a count of related records without loading the records themselves:
$users = User::withCount('posts')->get();
foreach ($users as $user) {
echo $user->posts_count; // Integer attribute — no collection loaded
}
withCount() adds a posts_count attribute via a subquery. Similarly, withSum(), withAvg(), and withMax() handle aggregates without loading full related collections.
Selecting Only Required Columns
Eager loading all columns is wasteful when you need only a subset:
$users = User::with('posts:id,user_id,title,created_at')->get();
Always include the foreign key. When selecting specific columns on an eager-loaded relationship, you must include the foreign key (
user_idhere). Without it, Eloquent cannot map posts back to their parent users, and$user->postswill return empty collections — silently, with no error.
Column selection on the parent model:
$users = User::select('id', 'name', 'email')
->with('posts:id,user_id,title')
->get();
If your posts table stores full article bodies and you only need titles for a list view, not selecting the content column meaningfully reduces both query time and PHP memory consumption. This compounds across large result sets.
Detecting N+1 Problems
Laravel Debugbar
Install barryvdh/laravel-debugbar in development. It displays every query that ran for each request — count, duration, SQL, and the PHP call stack that triggered it.
composer require barryvdh/laravel-debugbar --dev
When a page you expect to run in 5 queries is actually running 50, the debugbar shows you exactly what fired and from where.
Laravel Telescope
Laravel’s first-party debugging assistant records requests, queries, jobs, cache operations, and more in a persistent UI.
composer require laravel/telescope --dev
php artisan telescope:install
php artisan migrate
DB::listen() — Quick In-Code Debugging
use Illuminate\Support\Facades\DB;
DB::listen(function ($query) {
logger($query->sql, [
'bindings' => $query->bindings,
'time_ms' => $query->time,
]);
});
Drop this in a service provider temporarily. Every query appears in your log with SQL, bindings, and execution time — repeated patterns like SELECT * FROM posts WHERE user_id = ? firing dozens of times are immediately visible.
DB::getQueryLog() — Inline Inspection
DB::enableQueryLog();
// ...code under investigation...
$users = User::all();
foreach ($users as $user) { $user->posts->count(); }
$log = DB::getQueryLog();
dd(count($log), $log); // How many queries? What did they say?
Preventing Lazy Loading
Laravel can throw an exception whenever a relationship is lazily loaded — turning accidental lazy loading from a silent performance problem into an immediate, visible error during development.
// In AppServiceProvider::boot()
use Illuminate\Database\Eloquent\Model;
public function boot(): void
{
Model::preventLazyLoading(! app()->isProduction());
}
The ! app()->isProduction() guard is important. In development and testing, lazy loading throws a LazyLoadingViolationException:
Attempted to lazy load [posts] on model [App\Models\User]
but lazy loading is disabled.
In production, the guard disables the prevention — so if a lazy load sneaks through, it degrades performance silently rather than crashing for users.
Model::preventLazyLoading() is particularly valuable when onboarding new developers, refactoring controller logic, or running test suites where datasets are too small to reveal the performance problem naturally.
Common Eloquent Performance Mistakes
1. Loading relationships inside loops
// WRONG: query per iteration
foreach ($users as $user) {
$count = $user->posts->count(); // N queries
}
// BETTER: use withCount()
$users = User::withCount('posts')->get();
foreach ($users as $user) {
$count = $user->posts_count; // In-memory attribute
}
2. Confusing the relationship property and method
$user->posts returns the cached collection (or lazy-loads once and caches). $user->posts() returns a new query builder — it always queries, never uses the cache.
// WRONG: re-querying every time
if ($user->posts()->count() > 0) { // New query
show($user->posts()->get()); // Another new query
}
// BETTER: access the property
if ($user->posts->count() > 0) { // Lazy-loads once
show($user->posts); // Uses cached collection
}
3. Eager loading relationships you don’t use
Every relationship you add to with() is a query. Load only what your current request actually needs.
// WRONG: loading five relationships, using one
$users = User::with(['posts', 'comments', 'roles', 'permissions', 'profile'])->get();
// ...then only using $user->name in the template
4. Forgetting pagination
->get() fetches every matching row into PHP memory. Paginate by default; ->get() should be a deliberate choice, not a habit.
// WRONG: loading all users — no limit
$users = User::with('posts')->get();
// BETTER: paginate
$users = User::with('posts')->paginate(25);
5. Ignoring database indexes
Eager loading with WHERE user_id IN (...) is fast only if user_id is indexed. Eloquent generates good queries; your schema must support them.
// In your migration:
$table->foreignId('user_id')->constrained(); // Creates index automatically
6. Assuming the ORM optimizes queries for you
Eloquent helps you write queries. It doesn’t optimize them. Understanding the SQL your code generates — and verifying it through logging or Debugbar — is your responsibility.
Eager Loading Does Not Mean “Load Everything”
A common overreaction to learning about N+1 problems is adding with() to every query as a defensive habit. This creates different performance problems: loading megabytes of data you never use, inflating PHP memory, and adding unnecessary queries to requests that don’t need them.
The guiding question is simple:
“Will I access this relationship for every item in this collection?” If yes — eager load it. If no, or if you’re not sure, lazy loading is fine. Measure; don’t guess.
When Lazy Loading is appropriate:
- Working with a single model instance, not a collection
- The relationship is accessed conditionally — only in some code paths
- You’re in a service where callers decide what to do with the model
When Eager Loading is the right choice:
- Rendering a list where every item displays related data
- Building an API endpoint that serializes models with their relationships
- Processing a collection in a job or command where all records use the related data
Real-World Example: Optimizing an API Endpoint
Let’s build a GET /api/users endpoint that returns users with their posts and each post’s comment count.
The Naive Implementation
// Potentially 1,001+ queries
public function index(): JsonResponse
{
$users = User::all(); // No limit — loads every user
return response()->json(
$users->map(function ($user) {
return [
'id' => $user->id,
'name' => $user->name,
'post_count' => $user->posts->count(), // Lazy: N queries
'posts' => $user->posts->map(function ($post) {
return [
'title' => $post->title,
'comment_count' => $post->comments->count(), // N*M queries
];
}),
];
})
);
}
With 100 users and 10 posts each: 1 + 100 + 1,000 = 1,101 queries per request.
The Optimized Implementation
// ~3-4 queries per request, regardless of database size
public function index(Request $request): JsonResponse
{
$users = User::select('id', 'name', 'email') // Only needed columns
->withCount('posts') // Subquery, no collection loaded
->with([
'posts:id,user_id,title', // Eager + column selection
'posts.comments:id,post_id', // Nested eager, only IDs needed
])
->paginate(25); // Cap result set
return response()->json([
'data' => $users->map(function ($user) {
return [
'id' => $user->id,
'name' => $user->name,
'post_count' => $user->posts_count, // From withCount()
'posts' => $user->posts->map(function ($post) {
return [
'title' => $post->title,
'comment_count' => $post->comments->count(), // In-memory
];
}),
];
}),
'meta' => [
'total' => $users->total(),
'current_page' => $users->currentPage(),
'per_page' => $users->perPage(),
],
]);
}
| Change | Why |
|---|---|
select('id', 'name', 'email') |
Don’t load columns you don’t serialize |
withCount('posts') |
Gets post count via subquery; no collection in memory |
with(['posts:id,user_id,title']) |
Eager loads posts with only needed columns |
'posts.comments:id,post_id' |
Eager loads comments; count() in the map uses in-memory data |
paginate(25) |
Caps the result set; prevents loading unbounded data |
Performance Thinking: Queries vs Data
Here is a nuance that experienced developers understand and beginners often miss: fewer queries is not automatically faster.
Database performance is the result of several factors working together:
| Factor | Description |
|---|---|
| Number of queries | Round-trips to the database; each carries overhead |
| Amount of data | Rows × columns × row size = bytes on the wire and in memory |
| Query complexity | Joins, subqueries, sorting, and aggregates have planning costs |
| Index effectiveness | A table scan defeats a fast eager-loading query |
| Memory pressure | Loading 10,000 model instances into PHP consumes real RAM |
| Database concurrency | Many concurrent queries compete for connection pools |
The goal is not to minimize query count. The goal is to minimize the total cost of retrieving the data your application actually needs.
Practical heuristics:
- Collection with related data needed for all items: Eager load — always
- Single-item detail pages: Eager load the relationships your view renders; skip the rest
- Aggregate data: Use
withCount(),withSum(), or subqueries — don’t load collections just to count them - Conditional data: Consider lazy loading or AJAX load-on-demand
- Large result sets: Always paginate — an unconstrained
->get()on a table that might grow to a million rows is a time bomb
Best Practices Checklist
- Understand when relationships are loaded. Know the difference between lazy loading (default) and eager loading (
with()), and which one your code is using - Watch for relationship access inside loops. Every
$model->relationshipinside aforeachis a potential N+1 - Eager load when you know the data is needed. If you’ll access a relationship for every item in a collection, load it upfront
- Don’t eager load what you won’t use. Every relationship in
with()is a query — only load what your request actually needs - Select only required columns. Use
select()and therelationship:col1,col2syntax to avoid loading unused data - Paginate result sets. Never call
->get()on a query that could return an unbounded number of rows - Use
withCount()for aggregates. Don’t load full collections just to call->count()on them - Monitor generated queries in development. Use Laravel Debugbar or Telescope to verify your code generates the queries you expect
- Enable
Model::preventLazyLoading()in development. Let Laravel surface accidental lazy loading as an exception, not a silent drain - Index your foreign keys. Eager loading is only fast if
WHERE user_id IN (...)can use an index - Measure, don’t guess. Profile your queries in production. Intuition is a starting point; data is the answer
Conclusion
Eloquent is one of the most developer-friendly ORMs in any language. Its expressive API makes database interaction feel natural — and that naturalness is exactly what makes the N+1 problem so dangerous. The code that causes it looks right. The ORM does exactly what you asked. The problem is invisible until the moment it isn’t.
Understanding Lazy Loading and Eager Loading is not an advanced topic reserved for senior developers. It is the fundamental knowledge you need to write Eloquent code that works in production, not just in development. Every developer who touches an Eloquent model in a loop should understand what that loop is asking the database to do.
The progression from User::all() to User::with('posts.comments')->paginate(25) is not just about adding method calls. It reflects a mental model of your ORM: what it loads, when it loads, what it asks the database to do, and what the database pays for each request. That mental model is the difference between code that performs elegantly at scale and code that quietly buries your infrastructure under a thousand unnecessary queries.
Eloquent gives you the tools. Use them deliberately.