How I solved the N+1 query problem using JSON aggregation instead of traditional eager loading
tags: laravel, php, performance, database.
Last month, I was debugging a slow admin dashboard. The page loaded 500 partner records with their profiles, countries, and promotional codes. Each page load took over 2 seconds.
The culprit? The classic N+1 query problem.
The Problem Everyone Knows
Even with Laravel's eager loading, I was still hitting the database 5 times per request:
$partners = Partner::with(['profile', 'country', 'promocodes'])->get();
This generates:
SELECT * FROM partners -- Query 1
SELECT * FROM profiles WHERE partner_id IN... -- Query 2
SELECT * FROM countries WHERE id IN... -- Query 3
SELECT * FROM promocodes WHERE partner_id IN...-- Query 4
Each query means another round-trip to the database. With 50 records, that's 4 network round-trips, adding 15-20ms of latency each.
The "Aha!" Moment
I asked myself: "Can we load everything in ONE query?"
That's when I remembered MySQL's JSON_OBJECT and JSON_ARRAYAGG functions. What if instead of multiple queries, we could aggregate all relations into JSON directly in SQL?
The Solution: JSON Aggregation
I built a Laravel package that does exactly this:
$partners = Partner::aggregatedQuery()
->withJsonRelation('profile')
->withJsonRelation('country')
->withJsonCollection('promocodes')
->get();
This generates a single optimized query:
SELECT
base.*,
JSON_OBJECT('id', profile.id, 'name', profile.name) AS profile,
JSON_OBJECT('id', country.id, 'name', country.name) AS country,
(SELECT JSON_ARRAYAGG(JSON_OBJECT('id', id, 'code', code))
FROM promocodes WHERE partner_id = base.id) AS promocodes
FROM partners base
LEFT JOIN profiles profile ON profile.partner_id = base.id
LEFT JOIN countries country ON country.id = base.country_id
One query. All the data.
The Results
I ran benchmarks on a dataset of 2,000 partners with 4 relations each, fetching 50 records:
| Method | Time | Memory | Queries |
|---|---|---|---|
| Standard Eloquent | 27.44ms | 2.06MB | 4 |
| JSON Aggregation | 4.41ms | 0.18MB | 1 |
| Improvement | 83% faster | 91% less | 75% fewer |
That's not a typo. 83% faster.
Why Is It So Fast?
The performance gain comes from three factors:
1. Network Latency (80% of the gain)
Database round-trips are expensive. Even on localhost, each query adds 5-10ms. On a remote database? 15-20ms each.
Before: 4 queries × 15ms = 60ms in network time
After: 1 query × 15ms = 15ms
2. Avoiding Eloquent Hydration (15% of the gain)
By returning arrays instead of Eloquent models, we skip:
- Object instantiation
- Attribute casting
- Relationship binding
- Event firing
3. Optimized SQL (5% of the gain)
The database does the aggregation work using highly optimized C code instead of PHP loops.
Real-World Impact
On my dashboard handling 10,000 API requests per day:
- 40,000 fewer database queries
- 4 minutes saved in total response time
- 19GB less memory usage
- Better server resource utilization
How It Works
Installation
composer require rgalstyan/laravel-aggregated-queries
Setup
Add the trait to your model:
use Rgalstyan\LaravelAggregatedQueries\HasAggregatedQueries;
class Partner extends Model
{
use HasAggregatedQueries;
public function profile() {
return $this->hasOne(PartnerProfile::class);
}
public function promocodes() {
return $this->hasMany(PartnerPromocode::class);
}
}
Usage
// Instead of this:
$partners = Partner::with(['profile', 'promocodes'])->get();
// Do this:
$partners = Partner::aggregatedQuery()
->withJsonRelation('profile', ['id', 'name', 'email'])
->withJsonCollection('promocodes', ['id', 'code', 'discount'])
->where('is_active', true)
->get();
Output
The data structure is predictable and clean:
[
'id' => 1,
'name' => 'Partner A',
'profile' => [
'id' => 10,
'name' => 'John',
'email' => 'john@example.com'
],
'promocodes' => [
['id' => 1, 'code' => 'SAVE10'],
['id' => 2, 'code' => 'SAVE20']
]
]
Guaranteed:
- Relations are
arrayornull(never undefined) - Collections are always
array(nevernull) - No surprises in your code
When Should You Use This?
✅ Perfect for:
- API endpoints with multiple relations
- Admin dashboards with complex queries
- Mobile backends where every millisecond counts
- Read-heavy applications (90%+ reads)
- High-traffic services needing optimization
⚠️ Not recommended for:
- Write operations (use standard Eloquent)
- When you need model events/observers
- Complex nested relations (coming in v1.1!)
Performance vs. Eloquent Models
The package offers two modes:
// Array mode (default, fastest - 83% faster)
$partners = Partner::aggregatedQuery()->get();
// Eloquent mode (still faster - 27% improvement)
$partners = Partner::aggregatedQuery()->get('eloquent');
Array mode is fastest because it skips Eloquent's hydration overhead. But even in Eloquent mode, you still save 1 database query, which gives a significant boost.
Trade-offs
Let's be honest about limitations:
What you lose:
- Model events (no
created,updated,deletedevents) - Accessors/mutators (in array mode)
- The ability to call
save()orupdate()(read-only)
What you gain:
- 83% faster response times
- 91% less memory usage
- Simpler, more predictable data structures
- Better scalability
For read-heavy operations like APIs and dashboards, this trade-off is absolutely worth it.
What's Next?
I'm currently working on v1.1.0 with:
-
Nested relations (
profile.company.country) - Conditional loading with query constraints
- Relation aliasing
- Enhanced debugging tools
Try It Out!
If you're building APIs or dashboards with Laravel, give it a try:
🔗 GitHub: rgalstyan/laravel-aggregated-queries
📦 Packagist: composer require rgalstyan/laravel-aggregated-queries
I'd love to hear your results! Have you tried optimizing N+1 queries in other ways? Drop a comment below.
P.S. The package has been featured in Laravel News! If you find it useful, a GitHub star would mean a lot ⭐
Top comments (6)
I think the best solution for your case is working with the query builder or just use a raw SQL query.
Wanting to fit the query in an Eloquent jacket seems like looking for problems you do not need to solve.
I've been on a journey this year where I also started with doing everything in an ORM, and now I'm creating a library that only abstracts the database field and table names.
The main thing I figured out is that I don't want to burden myself having both the SQL and ORM mental model in my head when working with databases. When it comes down to it only the SQL mental model is the important one.
That’s a totally fair point, and I actually agree with a large part of it 👍
At a certain scale, everything is SQL, and understanding the SQL mental model is non-negotiable.
This package isn’t trying to hide SQL or pretend it doesn’t exist.
The problem I’m trying to solve is a slightly different one:
This package is essentially a thin, explicit escape hatch:
It’s not meant to replace raw SQL or the Query Builder.
If a team is already comfortable writing raw SQL everywhere, that’s often the best solution.
This is more for teams that:
So in short: I don’t see it as putting SQL into an Eloquent jacket,
but rather using Eloquent as a schema/metadata layer, while staying fully aware of the SQL underneath.
Eloquent has no column mappings. It is only when you start using accessors an mutators that Eloquent starts to abstract columns.
And if you really need a mapper, I recommend standalone mapper solutions like the Symfony object mapper.
ORM's are notoriously lacking in SQL features because one of their jobs is to work for all supported databases.
This means using raw SQL is a part of using an ORM when you are doing more than just CRUD queries.
So I don't really see how the mixing is the negative thing to do?
For me it is this thinking that gives many Lavavel applications technical debt.
Eloquent looks easy in the code but
Users::all()is justSELECT * FROM users, is one really harder than the other?Is using SQL joins harder than the different types of relationships?
A lot of people, me included, start to use an ORM because it is part of the framework. The question should be, which problem is the ORM solving for my application?
The more you think in abstractions, the less you are connected to the inner workings of the code.
If you ask 100 Laravel developers how much queries eager loading is generating, how many would know the right answer? It is advertised as the least amount of queries, and that is not even true.
The more complex querying you add, the more people are going to tune out the underlying SQL.
That is the reason I am not a big fan of libraries like yours. It is not that I don't appreciate the effort. And I do think you do it to help others.
Thanks for taking the time to write this — I get where you’re coming from, and I agree with a lot of it.
A few clarifications from my side:
1) “Duplicating column mappings”
You’re right that Eloquent isn’t a strict mapper like Doctrine. I used “mapping” loosely to mean “the implicit contract between model attributes and DB columns” (casts, hidden/visible, custom keys, timestamps, soft deletes, naming conventions, accessors/mutators, etc.). Once a codebase relies on that contract, people often end up re-implementing the same assumptions when they move to raw SQL/Query Builder everywhere.
2) “Mixing raw SQL and ORM logic”
I agree that real apps inevitably mix them. The problem I’ve seen isn’t the mixing itself — it’s the inconsistent mixing:
My goal is to provide a very explicit, narrowly scoped pattern: read-only, DTO-style lists where the output shape is stable and the query count is predictable.
3) “Keeping Eloquent as the primary model layer causes debt”
Totally valid in many teams. I’m not trying to push “ORM everywhere”. I’m saying: if a team has already committed to Eloquent for the domain layer, there’s still a big gap for read-heavy endpoints where:
This library is basically a “named escape hatch” for that gap.
4) “Abstractions disconnect people from SQL”
Also true. That’s why the package tries to be the opposite of “magic”:
So I fully respect the “just write SQL” philosophy — for many teams that’s the best path.
This library is for teams that want an explicit, consistent read-model approach while still living in an Eloquent-based codebase.
Appreciate the thoughtful pushback.
The first two points about the inconsistent mixing, why should those be for a library to solve? Isn't that a job for the application?
Don't jump scare me with statements like that. Eloquent has no place in a domain layer.
I do lean to that side, but I'm not against an ORM. An ORM is just a big beast to throw in an application from the start.
Yes, I agree with you that consistency and architecture are ultimately the application/team’s responsibility.
My point isn’t that a library can “fix” a poorly designed application.
Any tool can be misused — raw SQL, Query Builder, Eloquent, or my package.
What I’m aiming for is simply a reusable, explicit pattern for a very specific area:
read-only, DTO-style listing endpoints where you want predictable query count
and a stable output shape.
In a well-structured app this would live in the infrastructure/read-model/query layer,
not in the domain layer. I should have used that wording instead of “domain layer”.
So we’re probably closer than it sounds:
good architecture is required either way — the package just provides a consistent
implementation option for one common performance-sensitive use case.