A slow Laravel application is almost always caused by inefficient database queries, not by PHP itself. The good news is that Eloquent provides plenty of tools to make queries fast — as long as you know how to use them. This article covers the highest-impact Eloquent optimization techniques, from the most common (N+1) to indexing and caching, with real code.
1. Fix the N+1 Problem with Eager Loading
This is the number-one cause of slowness. N+1 happens when you fetch a set of records and then access their relationship inside a loop:
$orders = Order::all();
foreach ($orders as $order) {
echo $order->customer->name; // 1 EXTRA query per iteration
}
For 500 orders that means 501 queries. Use with() to load the relationship at once:
$orders = Order::with('customer')->get(); // only 2 queries
You can also load nested relationships and limit their columns:
$orders = Order::with([
'customer:id,name',
'items.product:id,name,price',
])->get();
To stop N+1 from reaching production, enable automatic prevention in AppServiceProvider:
use Illuminate\Database\Eloquent\Model;
public function boot(): void
{
Model::preventLazyLoading(! app()->isProduction());
}
2. Select Only the Columns You Need
By default Eloquent runs SELECT *. If a table has heavy columns like content or metadata, you waste memory and bandwidth. Fetch only what you need:
$users = User::select('id', 'name', 'email')->get();
If you only need a single column as an array, use pluck(), which is far leaner:
$emails = User::where('active', true)->pluck('email');
3. Process Large Data with chunk() and lazy()
Loading 100,000 rows with get() can exhaust memory. To process big data, fetch it in pieces:
User::chunk(500, function ($users) {
foreach ($users as $user) {
// process each user
}
});
A more modern alternative is lazy(), which returns a LazyCollection so you can keep using a normal foreach without straining memory:
foreach (User::lazy() as $user) {
// only a small slice of rows is in memory at any time
}
For mass updates, if you modify a column used as a condition, use chunkById() so you do not skip rows.
4. Add Database Indexes
Indexes are the most powerful way to speed up WHERE, ORDER BY, and JOIN queries. Columns you filter on often (like user_id, status, or email) should be indexed via a migration:
Schema::table('orders', function (Blueprint $table) {
$table->index('status');
$table->index(['user_id', 'created_at']); // composite index
});
Foreign keys should always be indexed. Without an index, the database must scan the entire table (a full table scan) for every query — very slow as data grows.
5. Cache Query Results That Rarely Change
Data like category lists or site settings rarely change but is fetched often. Store the result in cache so you do not hit the database repeatedly:
use Illuminate\Support\Facades\Cache;
$categories = Cache::remember('categories', 3600, function () {
return Category::orderBy('name')->get();
});
remember() returns data from cache if present; otherwise it runs the query, stores it for 3600 seconds, and returns it. Remember to clear the cache when data changes with Cache::forget('categories').
6. Find Slow Queries
You cannot optimize what you do not measure. A quick way to monitor every query is DB::listen in AppServiceProvider:
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
DB::listen(function ($query) {
if ($query->time > 100) { // more than 100 ms
Log::warning('Slow query', [
'sql' => $query->sql,
'time' => $query->time,
]);
}
});
For local development, install Laravel Debugbar, which shows the query count, duration, and duplicates right in the browser:
composer require barryvdh/laravel-debugbar --dev
Debugbar highlights N+1 problems instantly because you see the same query repeated many times.
7. Count Correctly
To count rows, never use count(User::all()), which loads all data into memory. Use a database aggregate:
$total = Order::where('status', 'paid')->count();
To check whether data exists, exists() is faster than count() > 0:
if (Order::where('user_id', $id)->exists()) {
// ...
}
8. Push Heavy Calculations to the Database
Another common mistake is fetching many rows into PHP just to sum or average their values. Hand that work to the database, which is far more efficient at aggregation:
// Less efficient: loads all rows into memory
$total = Order::where('status', 'paid')->get()->sum('amount');
// Efficient: the database does the math
$total = Order::where('status', 'paid')->sum('amount');
The same applies to avg(), max(), and min(). For statistics that group data, use groupBy() at the query level so only the summarized result is sent to PHP. This can turn a query that loads thousands of rows into a single result row.
If you are counting related records, avoid loading the entire relationship just to count it. Use withCount():
$posts = Post::withCount('comments')->get();
// access via $post->comments_count without loading the comments
Conclusion
Optimizing Eloquent queries is not magic — it is measure, then fix. Your priorities: eliminate N+1 with eager loading, select only the columns you need, index frequently filtered columns, process large data with chunk/lazy, cache data that rarely changes, and push aggregation to the database. With Debugbar or DB::listen as your eyes, a once-heavy Laravel app can become light and responsive. Apply these one by one, measure the impact, and you will see response times drop dramatically.