What They Don't Tell You About Scaling Eloquent for Millions of Rows
Laravel developers love Eloquent. And for good reason. As it makes database interactions expressive, readable, and incredibly productive. You can build an entire business application without writing a single raw SQL query.
The problem?
Most Eloquent examples are demonstrated against tables containing a few hundred or a few thousand records.
Everything looks fast. Everything feels elegant. Then one day your application reaches:
- 5 million users
- 20 million orders
- 100 million activity logs
Suddenly endpoints that once responded in 50ms start taking multiple seconds. Database CPU usage spikes. Queue workers consume gigabytes of memory. A simple dashboard query brings production to its knees.
I've seen this happen repeatedly.
The uncomfortable truth is: Eloquent scales remarkably well... until it doesn't.
The goal isn't to avoid Eloquent. The goal is to understand where its abstractions starts becoming expensive and knowing when to switch strategies.
🔥The Real-World Bottleneck
$users = User::whereHas('orders', function ($query) {
$query->where(
'created_at',
'>=',
now()->subDays(30)
);
})->get();
Until:
📊 Understanding What Eloquent Actually Generates
That query roughly becomes:
SELECT *
FROM users
WHERE EXISTS (
SELECT 1
FROM orders
WHERE orders.user_id = users.id
AND created_at >= ?
)As the data scales, this forces the database optimizer to work much harder.
In production, I've seen nested EXISTS queries become the primary source of slow queries once datasets exceed tens of millions of rows.
A common belief here is that assuming Eloquent abstractions are always free. But actually they're not.
Every relationship abstraction eventually translates into SQL. The database executes SQL—not Eloquent.
🎯Indexes Matter More Than Eloquent
Consider:
Order::where('status', 'completed')
->where('created_at', '>=', now()->subMonth())
->get();
Bad Index Strategy:
⚠️ Why whereHas Can Become Dangerous
Developers often chain multiple relationships as below
User::whereHas('orders', function ($query) {
$query->where('status', 'completed');
})
->whereHas('subscriptions')
->whereHas('payments')
->get();
This reads beautifully but the generated SQL often does not as much as optimized. Multiple nested existence checks can become extremely expensive.
Alternative Approach
Sometimes a direct join performs significantly better:
User::query()
->join(
'orders',
'orders.user_id',
'=',
'users.id'
)
->where('orders.status', 'completed')
->select('users.*')
->distinct()
->get();
Now this might not seem much elegant but its much faster now. This is one of those situations where understanding SQL matters more than framework knowledge.
🧠 The Hidden Cost of Eloquent Models
$users = User::all();It creates:
- Model objects
- Attribute arrays
- Relationship containers
- Event capabilities
- Casting infrastructure
Query Builder vs Eloquent vs Raw SQL
Use Eloquent When:
- You need relationships
- You need model events
- You need mutators and casts
- You need rich domain behavior
- Standard CRUD operations
$orders = Order::with('items')
->latest()
->paginate();Use Query Builder When:
- Reading large datasets
- Reports and exports
- Background jobs
- Aggregations
$users = DB::table('users')
->select('id', 'email')
->get();Use Raw SQL When:
- Complex analytics
- Window functions
- Advanced aggregations
- Highly optimized reporting
$results = DB::select(
'
SELECT
customer_id,
SUM(total) as revenue
FROM orders
GROUP BY customer_id
ORDER BY revenue DESC
LIMIT 100
'
);
🚨The "Load Everything Into Memory" Disaster
User::all()->each(function ($user) {
$this->sendPromotion($user);
});
The Correct Approach: chunkById()
User::query()
->chunkById(
1000,
function ($users) {
foreach ($users as $user) {
$this->sendPromotion($user);
}
}
);Benefits:
Why chunk() Can Still Bite You
Many developers use:
User::chunk(1000, function ($users) {
// Logic
});Instead:
User::chunkById(1000, function ($users) {
// Lgic
});Lazy Collections: The Forgotten Performance Weapon
Instead of:
$users = User::all();
foreach ($users as $user) {
// Logic to process records
}Use:
User::lazy()->each(function ($user) {
// Logic to process records
});⚡N+1 Queries Become Catastrophic at Scale
The classic example:
$users = User::all();
foreach ($users as $user) {
echo $user->orders->count();
}
With: 1000 users, you may execute: 1001 queries
With: 100,000 users, things become much worse.
Better:
$users = User::with('orders')->get();
Even Better:
If you only need counts:
$users = User::withCount('orders')->get();
Now the database does the aggregation. Much more efficient.
🏗️ Database Views: The Performance Tool Laravel Developers Forget Exists
SELECT
users.id,
users.name,
SUM(orders.total) as revenue,
COUNT(orders.id) as orders_count,
MAX(payments.created_at) as last_payment
FROM users
JOIN orders ON orders.user_id = users.id
JOIN payments ON payments.order_id = orders.id
GROUP BY users.id;
- Admin dashboards
- Revenue reports
- Scheduled exports
- Analytics endpoints
- Internal APIs
Using Database Views
Instead:
CREATE VIEW customer_summary AS
SELECT
users.id,
users.name,
SUM(orders.total) as revenue,
COUNT(orders.id) as orders_count
FROM users
JOIN orders ON orders.user_id = users.id
GROUP BY users.id;
Laravel can treat it like a normal table:
$customers = DB::table('customer_summary')
->where('revenue', '>', 10000)
->get();The Important Limitaion
A common misconception is:
🚀 Sometimes Denormalization Beats Perfect Database Design
A dashboard query may need:
- orders
- customers
- addresses
- countries
- states
- currencies
- products
- categories
Strategic Denormalization
$order->customer->country->name
customer_country_name
Example:
Order::create([
'customer_id' => $customer->id,
'customer_country_name' => $customer->country->name,
]);
Common candidates include:
- Customer name
- Country name
- Product category name
- Aggregated counters
- Revenue totals
- Lifetime purchase counts
The Trade-Off
You gain:
But you lose:
🏗️ When Even Query Builder Isn't Enough: CQRS
Example:
- orders: 100 million rows
- customers: 20 million rows
- payments: 500 million rows
Traditional Approach:
Dashboard Query
↓
Operational Tables
CQRS Approach:
Write Model
↓
Events
↓
Read Model
↓
Dashboard Queries
Instead of:
JOIN 8 tables
GROUP BY multiple columns
your query:
SELECT *
FROM customer_revenue_summary⚙️ Edge Case #1: Exporting Millions of Records
The naive approach:
$users = User::all();
Instead:
User::lazy()
->each(function ($user) use ($file) {
fputcsv(
$file,
[
$user->id,
$user->email
]
);
});
⚙️ Edge Case #2: Background Jobs Processing Huge Tables
Imagine:
ProcessInactiveUsersJob
Bad:
User::all();Good:
User::where(
'last_login_at',
'<',
now()->subYear()
)
->chunkById(
1000,
function ($users) {
foreach ($users as $user) {
// process
}
}
);
🧠 The Most Important Scaling Lesson
- Database design
- Index strategy
- Query architecture
- Read/write separation
- Reporting models
- Denormalization decisions
- Are the indexes correct?
- Are we overusing whereHas() or whereExist()?
- Are we loading unnecessary models?
- Should this query use Query Builder instead?
- Should this become a View?
- Should this data be denormalized?
- Is this actually a CQRS read-model problem?
⚖️ The Engineering Trade-Offs
| Approach | Best For | Pros | Cons |
|---|---|---|---|
| Eloquent | Business logic & CRUD | Fast development, expressive syntax | Hydration overhead on large datasets |
| Query Builder | Large reads, reports, exports | Faster execution, lower memory usage | Less domain abstraction |
| Raw SQL | Complex analytics and aggregations | Maximum control and performance | Harder to maintain and test |
| Database Views | Reusable reporting queries | Centralized query logic, cleaner code | Doesn't automatically improve performance |
| Denormalized Tables | High-volume reporting and dashboards | Fewer joins, faster reads | Data duplication and consistency challenges |
| CQRS | Massive-scale systems | Extremely fast read performance | Higher architectural complexity |
-compressed.jpg)