Introduction #
As we settle into 2026, the PHP landscape has matured significantly. Laravel continues to dominate the ecosystem, not just because of its ease of use, but because it has evolved into an enterprise-grade framework capable of handling massive data loads. However, there is a distinct gap between a “Laravel developer” and a “Senior Laravel Architect.” That gap is often defined by one thing: Database Interaction.
Eloquent ORM is a double-edged sword. Its active record implementation is beautiful and expressive, allowing you to write code that reads like English. But under the hood, it can be a silent performance killer. A simple $user->posts call can trigger hundreds of queries if you aren’t careful, and bloated models with dozens of scopes can make your codebase unmaintainable.
In this deep dive, we aren’t just going to cover User::all(). We are going to restructure how you interact with your database. We will implement Custom Query Builders to clean up your models and controllers, and then we will switch gears to High-Performance Optimization, covering subqueries, indexing, and memory management for large datasets.
By the end of this article, you will have the toolkit to refactor your legacy monolithic models into clean, encapsulated, and lightning-fast architectural components.
Prerequisites and Environment #
To get the most out of this guide, you should be comfortable with basic Laravel concepts. We are focusing on modern standards prevalent in late 2025 and early 2026.
Environment Requirements:
- PHP: Version 8.3 or 8.4 (We will use typed properties and constructor promotion).
- Laravel: Version 11.x or 12.x.
- Database: MySQL 8.0+ or PostgreSQL 15+.
- Tools: Laravel Debugbar or Telescope (essential for profiling queries).
If you are setting up a fresh sandbox to follow along:
composer create-project laravel/laravel eloquent-advanced
cd eloquent-advanced
composer require barryvdh/laravel-debugbar --devPart 1: The Case for Custom Query Builders #
The Bloated Model Anti-Pattern #
In a standard Laravel application, it’s common to see Models stuffed with “Scoping” logic. Does this look familiar?
// app/Models/Order.php
class Order extends Model
{
public function scopeActive($query)
{
return $query->where('status', 'active');
}
public function scopeHighValue($query)
{
return $query->where('total', '>', 500);
}
public function scopeFromEurope($query)
{
return $query->whereIn('country_code', ['DE', 'FR', 'IT', 'ES']);
}
}While convenient, this violates the Single Responsibility Principle. Your Model should define the shape of your data and its relationships, not the infinite variations of how you might want to query it. Furthermore, relying on magic methods (Order::active()->highValue()) means your IDE often loses autocomplete support, making refactoring a nightmare.
The Solution: Dedicated Builders #
The cleanest way to handle complex filtering logic in Laravel is to create a Custom Query Builder. This acts as a layer between your Model and the Database Driver, encapsulating all business logic related to fetching data.
Here is the architectural flow we are aiming for:
Implementation Step-by-Step #
Let’s build a robust system for an E-commerce platform.
1. Create the Custom Builder #
Create a directory app/Builders. We will create an OrderBuilder class that extends Illuminate\Database\Eloquent\Builder.
<?php
namespace App\Builders;
use Illuminate\Database\Eloquent\Builder;
use Carbon\Carbon;
/**
* @template TModelClass of \App\Models\Order
* @extends Builder<TModelClass>
*/
class OrderBuilder extends Builder
{
public function active(): self
{
return $this->where('status', 'active');
}
public function highValue(float $threshold = 500.00): self
{
return $this->where('total_amount', '>', $threshold);
}
public function recent(int $days = 30): self
{
return $this->where('created_at', '>=', Carbon::now()->subDays($days));
}
public function released(): self
{
return $this->whereNotNull('released_at')
->where('released_at', '<=', Carbon::now());
}
/**
* Sort by most relevant (custom logic)
*/
public function popular(): self
{
return $this->orderByDesc('views_count')
->orderByDesc('total_amount');
}
}2. Connect the Builder to the Model #
Now, we need to tell the Order model to use this builder instead of the default one. We do this by overriding the newEloquentBuilder method.
<?php
namespace App\Models;
use App\Builders\OrderBuilder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Factories\HasFactory;
class Order extends Model
{
use HasFactory;
protected $table = 'orders';
protected $casts = [
'total_amount' => 'float',
'released_at' => 'datetime',
];
/**
* Override the default Eloquent Builder
*
* @param \Illuminate\Database\Query\Builder $query
* @return OrderBuilder
*/
public function newEloquentBuilder($query): OrderBuilder
{
return new OrderBuilder($query);
}
}3. Usage in Controller #
Now, your usage in the controller is clean, type-safe, and highly readable. Your IDE will now autocomplete methods like highValue because we typed the builder return in the model.
// app/Http/Controllers/OrderController.php
public function index()
{
// Chaining methods fluently
$orders = Order::query()
->active()
->recent(7)
->highValue(1000)
->popular()
->with('items') // Standard Eloquent methods still work!
->paginate(20);
return view('orders.index', compact('orders'));
}Part 2: Advanced Optimization Techniques #
Now that our architecture is clean, let’s talk about speed. In 2026, users expect instant load times. If your dashboard takes 3 seconds to load because of inefficient SQL, you are failing.
1. The N+1 Problem (and Strict Mode) #
The N+1 problem is the most common Eloquent pitfall. It happens when you load a relationship inside a loop that wasn’t eager loaded.
The Problem:
$users = User::all(); // 1 Query
foreach ($users as $user) {
echo $user->profile->bio; // N Queries (one for every user)
}The Solution: Use Eager Loading.
$users = User::with('profile')->get(); // 2 Queries total
Pro Tip: Enable Strict Mode
In Laravel 11+, you should enable “Strict Mode” in your AppServiceProvider for local environments. This throws an exception if you attempt a lazy load, forcing you to fix it before production.
// app/Providers/AppServiceProvider.php
use Illuminate\Database\Eloquent\Model;
public function boot(): void
{
Model::shouldBeStrict(! $this->app->isProduction());
}2. Subqueries over Relationships (The “Select Sub” Technique) #
This is an advanced technique often missed by mid-level developers. Sometimes you don’t need the whole relationship model; you just need a count or the last item. Loading the full model object consumes memory.
Scenario: You want to list users and the date of their last login.
Inefficient Way (Memory Heavy):
$users = User::with('logins')->get();
// In Blade: $user->logins->sortByDesc('created_at')->first()->created_at
// This loads ALL login records into memory just to pick one date.
Efficient Way (Subqueries):
We use addSelect to inject a raw subquery into the main SELECT statement.
use App\Models\LoginLog;
$users = User::query()
->addSelect(['last_login_at' => LoginLog::select('created_at')
->whereColumn('user_id', 'users.id')
->latest()
->limit(1)
])
->withCasts(['last_login_at' => 'datetime']) // Cast the virtual column
->paginate(20);Now, $user->last_login_at is available as a property, but we never hydrated the LoginLog models. This reduces memory usage drastically for large datasets.
3. Memory Management: get() vs cursor() vs chunk()
#
When processing thousands of records (e.g., creating a CSV export), get() will crash your server by loading everything into RAM.
Let’s compare the methods:
| Method | Behavior | Memory Usage | Use Case |
|---|---|---|---|
get() |
Loads all results into a PHP Collection. | High (O(n)) | Small datasets, displaying API responses. |
chunk(n) |
Loads n records at a time, clears memory after closure. |
Medium (O(1) relative to total) | Batch processing, updating records. |
cursor() |
Uses a PHP Generator to stream one record at a time from DB. | Low (Very efficient) | Heavy lifting, CSV exports, single-pass logic. |
lazy() |
Similar to cursor but returns LazyCollection. | Low | Modern alternative to cursor in Laravel 9+. |
Example: Efficient CSV Export
use App\Models\Order;
use Illuminate\Support\Facades\Storage;
public function exportOrders()
{
$handle = fopen(storage_path('app/orders.csv'), 'w');
// cursor() keeps only one model in memory at a time
foreach (Order::query()->cursor() as $order) {
fputcsv($handle, [$order->id, $order->total_amount, $order->created_at]);
}
fclose($handle);
}Part 3: Indexes and Database Design #
Eloquent cannot fix a bad database design. As a senior developer, you must understand how your queries translate to SQL and how MySQL/PostgreSQL executes them.
Composite Indexes #
If you frequently query by multiple columns, a single index isn’t enough.
// Query
User::where('status', 'active')->where('role', 'admin')->get();If you have an index on status and an index on role, MySQL will likely pick one and scan the results. You need a Composite Index.
In your migration:
$table->index(['status', 'role']); // Order matters!
Note: The order of columns in the index should match the order of specificity or how you query them (Left-most prefix rule).
Virtual Generated Columns #
In 2026, JSON columns are everywhere. However, searching JSON is slow.
WHERE data->'address'->>'zip' = '90210' cannot use standard indexes easily.
Solution: Use Virtual Generated Columns in MySQL 8+.
// Migration
Schema::table('users', function (Blueprint $table) {
// Create a virtual column extracted from JSON
$table->string('zip_code')
->virtualAs('data->"$.address.zip"')
->index(); // Index the virtual column!
});Now, Eloquent can query where('zip_code', '90210') and it will be blazing fast, indexed, but the data physically lives inside the JSON.
Part 4: Practical Example - The Dashboard Controller #
Let’s combine Custom Builders, Optimization, and Subqueries into a production-ready Dashboard Controller.
<?php
namespace App\Http\Controllers;
use App\Models\User;
use App\Models\Order;
use Illuminate\Http\JsonResponse;
class DashboardController extends Controller
{
public function stats(): JsonResponse
{
// 1. Using Custom Builder for readable filtering
// 2. Using count() directly instead of get()->count()
$activeOrdersCount = Order::query()
->active()
->count();
// 3. Subquery for revenue to avoid loading models
$totalRevenue = Order::query()
->active()
->sum('total_amount');
// 4. Advanced: Get top 5 users with their total spend
// Using strict mode compliant eager loading
$topUsers = User::query()
->select(['id', 'name', 'email']) // Select only what is needed
->withSum(['orders' => function ($query) {
$query->active(); // Reuse builder logic inside relation
}], 'total_amount')
->orderByDesc('orders_sum_total_amount')
->limit(5)
->get();
return response()->json([
'orders_active' => $activeOrdersCount,
'revenue' => $totalRevenue,
'top_spenders' => $topUsers
]);
}
}Why this code is “Senior Level”: #
- Readability:
Order::query()->active()explains itself. - Performance:
sumandcounthappen in the database, not PHP. - Memory:
select(['id', 'name'])prevents fetching huge text fields (likebioorlogs) that aren’t needed for this chart. - Reusability: The closure inside
withSumreuses theactive()scope from our Custom Builder, ensuring “Active Order” means the same thing everywhere in the app.
Part 5: Common Pitfalls and Debugging #
Even with these tools, things go wrong. Here is how to catch them.
1. The latest() Trap
#
User::latest()->get() generates ORDER BY created_at DESC. If created_at is not indexed, this causes a “File Sort” in the database, which is slow for millions of rows. Always index sort columns for large tables.
2. whereHas Performance
#
whereHas is powerful but generates an EXISTS subquery. On massive tables, this can be slow.
If possible, use a join or denormalize data (e.g., adding last_order_at to the users table) if you run this query frequently.
3. Profiling with Telescope #
Don’t guess. Measure. Use Laravel Telescope in local development. Look at the Queries tab.
- Look for duplicate queries.
- Look for queries taking > 50ms.
- Check the “Hydrated Models” count. If you loaded 10,000 models for a request, you have a problem.
Conclusion #
Mastering Eloquent is about moving beyond the basics of “Active Record” and treating your application as a system of data flows.
By adopting Custom Query Builders, you encapsulate business logic, making your application maintainable and testable. By understanding Database Optimization—specifically strict mode, subqueries, and proper indexing—you ensure your application scales gracefully into 2026 and beyond.
Key Takeaways:
- Stop writing logic in Controllers: Move query logic to Custom Builders.
- Stop loading everything: Use
select(),with(), and subqueries (addSelect) to fetch only what you need. - Respect Memory: Use
cursor()orchunk()for bulk operations. - Index wisely: Use composite indexes and virtual columns for JSON data.
Refactoring a legacy codebase takes time, but starting with these patterns today will save you hundreds of hours of technical debt tomorrow.
Further Reading #
- Laravel Documentation: Eloquent Builders
- MySQL 8.0 Reference: Generated Columns
- Jonathan Reinink’s “Eloquent Performance Patterns” (Highly recommended for deep SQL understanding).
Happy Coding!