If your Laravel app feels slow, your instinct is probably wrong. It’s not the framework, it’s not PHP, and it’s almost certainly not your server plan. Nine times out of ten, it’s your queries: too many of them, too slow, or both. The good news is that query slowness is the most fixable kind of slowness there is — and most of it is fixed in code, not by paying your host more money.
This is a practical checklist to speed up Laravel queries before you touch your server plan. Measure first, fix in order of impact, and re-measure. Date-stamped October 2026; everything below is written against Laravel 13.x behaviour, verified against the current docs and fresh community write-ups.
1. Measure first: count your queries before you “optimise”
You cannot speed up Laravel queries you can’t see. Before changing anything, put a number on the problem. Two built-in ways:
// Throwaway snippet, run it in a controller or tinker:
DB::enableQueryLog();
// ... your normal code here, e.g. loading a listing page ...
dd(DB::getQueryLog());
DB::enableQueryLog() records every query with its SQL, bindings, and execution time in milliseconds. If the count grows with the number of rows on the page — 51 queries for 50 orders, 301 for 300 — you’ve found your problem, and it’s the N+1 problem (fix #2). A community N+1 measuring write-up puts it bluntly: when each query takes under a millisecond, the slow-query log never fires — the count is the cost.
For ongoing work, install Laravel Telescope in development (composer require laravel/telescope --dev, then php artisan telescope:install and php artisan migrate) and watch the Queries tab. Telescope tags anything slower than 100 ms as slow, and repeated near-identical selects with only the ID changing are the signature of an N+1. The Laravel Debugbar package (composer require barryvdh/laravel-debugbar --dev) gives you a live query counter in the browser toolbar — as a rule of thumb, anything above 20–30 queries for a simple listing page deserves investigation.
2. Kill the N+1 with eager loading
This is the single biggest query win in Laravel, and the single most common query bug. Watch what happens when you loop over users and touch a relationship:
// Bad: 1 query for users + 1 per user for their posts = N+1
$users = User::all();
foreach ($users as $user) {
echo $user->posts->count();
}
// Good: exactly 2 queries, no matter how many users
$users = User::with('posts')->get();
foreach ($users as $user) {
echo $user->posts->count();
}
With with('posts'), Laravel fetches all posts in one extra query using where user_id in (…) and matches them up in memory. Nested relationships use dot notation — with(['posts.comments', 'posts.author']) — and you can constrain them:
$posts = Post::with(['comments' => function ($query) {
$query->where('approved', true)->latest();
}])->get();
If you’ve already fetched the models and then realise you need a relation, $users->load('posts') (or loadMissing(), which only loads what’s missing) backfills them in one batched query instead of one per model.
3. Turn on automatic eager loading — the safety net you never switched on
Here’s the Laravel feature almost nobody enables. Since Laravel 12.8, Eloquent can eager-load relationships for you automatically. Enable it once in app/Providers/AppServiceProvider.php:
use Illuminate\Database\Eloquent\Model;
public function boot(): void
{
Model::automaticallyEagerLoadRelationships();
}
From that point on, the first time you touch a missing relation on any model in a collection, Laravel runs loadMissing() for the whole collection in one query instead of lazy-loading it row by row. Users, then posts, then comments becomes 3 queries instead of 61 — and it works down the chain. If you don’t want it globally, you can opt in per query with $users->withRelationshipAutoloading() on a single Eloquent collection. Both are documented in the 13.x Eloquent relationships docs.
Two honest caveats. First, this feature is still young (it shipped in April 2025), so test it before trusting it blindly in a weird codebase. Second, it is not a replacement for thinking: relationship methods like $post->comments()->count() — note the parentheses — still run a fresh query per row, because they bypass the relationship cache entirely. For counts, you want withCount() (fix #6).
4. Make lazy loading throw in development
The N+1 problem keeps coming back because lazy loading fails silently — you add a field to a Blade view, nobody updates the with(), and the query count creeps up. Laravel lets you flip that: make lazy loading throw an exception outside production.
use Illuminate\Database\Eloquent\Model;
public function boot(): void
{
// Throw on lazy loads in every environment except production
Model::preventLazyLoading(! app()->isProduction());
}
Now a missing with() blows up loudly in development instead of quietly adding queries in production. For production itself, you can log violations instead of throwing, so you find the stragglers without breaking pages:
Model::preventLazyLoading();
Model::handleLazyLoadingViolationUsing(function ($model, $relation) {
logger()->warning("Lazy load detected: {$relation} on " . get_class($model));
});
Individual models can opt out when lazy loading is genuinely fine (a tiny settings table, say) with public $preventLazyLoading = false;, and Model::withoutPreventing(...) wraps a block where you want to allow it.
5. Stop selecting * — pick your columns
SELECT * is convenient and expensive: more data over the wire, more memory to hydrate, slower serialization. If your listing page only shows a name and an email, say so:
$users = User::select('id', 'name', 'email')->get();
The same applies to eager loads — you can limit the related columns too:
$users = User::with('profile:id,user_id,avatar')->get();
The trap: when you narrow columns on an eager-loaded relation, you must include the foreign key (here user_id), or Eloquent can’t match the children to their parents and you’ll get empty relations. Include the parent’s key as well whenever you’re selecting on the parent model inside the same query.
6. Use withCount() for counts and exists() for checks
Two of the most common query smells in Laravel code are loading a whole relationship just to count it, and loading a whole model just to check it exists.
// Bad: loads every post into memory to count them
$users = User::all();
foreach ($users as $user) {
echo count($user->posts);
}
// Good: one aggregate per relation, zero loaded models
$users = User::withCount('posts')->get();
foreach ($users as $user) {
echo $user->posts_count;
}
// Bad: selects a full row to answer a yes/no question
if (Order::where('user_id', $id)->count() > 0) { ... }
// Good: stops at the first matching row
if (Order::where('user_id', $id)->exists()) { ... }
withCount() adds a {relation}_count attribute via a subquery — one query total, no hydration. exists() lets the database short-circuit as soon as it finds one row, which is measurably cheaper than counting everything.
7. Index what you filter, join, and sort
No amount of Eloquent cleverness fixes a missing index. If your query filters on status, joins on user_id, or orders by created_at, those columns need indexes — foreign keys doubly so, because every eager-loaded where in (…) joins against them. Add them in a migration:
Schema::table('orders', function (Blueprint $table) {
$table->index('status');
$table->index(['user_id', 'created_at']); // composite: filter + sort
});
And check your work. Laravel’s query builder has an explain() method that runs EXPLAIN against the database and returns the rows:
$rows = DB::table('orders')
->where('status', 'pending')
->explain();
// 'type' => 'ALL' means a full table scan: add an index.
foreach ($rows as $row) {
dump($row->type, $row->key, $row->rows);
}
If the plan says ALL (full table scan) on a query that should be selective, or the key column is null, your index is missing. For MySQL, EXPLAIN ANALYZE goes further and shows actual execution times per step.
8. Chunk, lazy, and cursor your way through big data
Loading 100,000 rows into memory to loop over them is never fast, no matter how good the query is. Laravel gives you three tools depending on the job:
// Batch processing (updates, exports): constant memory, one chunk at a time
Order::chunk(1000, function ($orders) {
foreach ($orders as $order) {
// process
}
});
// Streaming without the callback dance
foreach (Order::where('status', 'pending')->lazy() as $order) {
// process
}
For user-facing pagination on huge tables, cursorPaginate() is the fastest option because it skips the offset: deep pages with offset/limit force the database to count through rows it will discard. If you don’t need page numbers at all, simplePaginate() drops the count(*) query that regular paginate() runs on every request — one less query on every page view, which matters on high-traffic listings.
9. Cache the expensive aggregations
That dashboard widget showing total orders, revenue, and active customers runs three aggregate queries on every single page load — for numbers that change maybe twice an hour. Cache them:
$stats = Cache::remember('dashboard_stats', now()->addMinutes(30), function () {
return [
'orders' => Order::count(),
'revenue' => Order::sum('amount'),
'customers' => User::where('active', true)->count(),
];
});
Caching a dashboard block like this can cut its database load by 90%+ overnight. Rules that keep it safe: cache derived numbers, never user-specific data you can’t bust reliably; keep the TTL honest (if it must be live, don’t cache it); and bust on write — forget the key in the same code path that changes the underlying data.
10. Move report queries off the request path
The previous nine fixes cover the request path. The tenth is knowing when a query doesn’t belong on the request path at all. Heavy reports, CSV exports, analytics rollups — these should run in queued jobs and be stored or streamed back, not hold a web request open.
// Controller: fast, queues the work, returns immediately
GenerateMonthlyReport::dispatch($user, $month);
return response()->json(['message' => 'Report is being generated — we will email it.']);
If you’re generating the same report repeatedly, stop re-running the same expensive joins every time: compute once into a summary table on a schedule (a database view for frequent multi-table sums works too) and query the small table. The report gets faster and your live tables get less traffic.
When it’s genuinely the database, not the code
Sometimes you do all ten and a query is still slow. At that point it’s the database itself: check the slow-query log on your MySQL/Postgres server, look at connection counts (a too many connections error is a pooling problem, not a code problem — see our guide on running Laravel queue workers in production), and make sure you haven’t outgrown shared hosting for the database. But be honest with yourself: in the author’s experience, roughly 9 of 10 “slow Laravel” reports are fixed by items 1–7 above. Upgrade the server last, not first.
The 10-minute checklist
Next time a page feels slow, work down this list:
- Count the queries with
DB::getQueryLog()or Debugbar — is the count the problem, or the time? - Add
with()everywhere a loop touches a relationship. - Consider
Model::automaticallyEagerLoadRelationships()as a safety net. - Enable
Model::preventLazyLoading()outside production so the N+1 can’t sneak back. - Replace
select *with the columns you actually render. - Replace count-in-a-loop with
withCount()and count-checks withexists(). - Index filtered/joined/sorted columns; verify with
->explain(). - Chunk large datasets; cursor-paginate deep listings;
simplePaginate()when you don’t need page numbers. Cache::remember()the dashboard widgets and other expensive aggregates.- Queue reports and exports; precompute repeated rollups.
Further Reading & References
- Laravel 13.x Eloquent Relationships docs — automatic eager loading,
withRelationshipAutoloading(),preventLazyLoading() - Laravel Automatic Eager Loading: Kill N+1 in One Line — the freshest deep-dive on
automaticallyEagerLoadRelationships(), incl. the$post->comments()->count()trap (Oct 2026) - Measuring Queries Before You Fix Them — query counting and timing methodology, incl. the “count is the cost” insight
- N+1 Query Detection and Prevention in Laravel Production Apps —
preventLazyLoading(),handleLazyLoadingViolationUsing(), per-model opt-outs - Laravel’s Safety Mechanisms — PlanetScale’s guide to preventing lazy loading
- Laravel Telescope Guide: Debug Requests, Queries and Jobs — spotting N+1 in Telescope’s Queries tab, the 100 ms slow tag
- How to Fix the N+1 Query Problem in Laravel — eager loading explained with the query-log detection method (Oct 2026)
- Laravel Performance Optimization: 7 Proven Techniques — indexes, column selection, cursor pagination, aggregation caching (Oct 2026)



