A Laravel app that feels instant with a hundred rows can crawl with a hundred thousand. The code didn't change; the data did. Almost every time, the cause is the database: too many queries, queries that read too much, or queries that can't use an index. Eloquent makes all three easy to write by accident, and almost as easy to fix once you can see them.
This guide follows the order I use on real projects: measure, fix query count, fix query size, fix indexes, then cache what is still expensive.
Measure before changing anythingLink to this section
You need two numbers for a slow page: how many queries it runs and how long the slowest one takes.
- Laravel Debugbar (development) shows every query, its time and where it came from.
- Laravel Telescope records requests, queries and jobs, which helps with slow API endpoints and queued work.
- A quick listener works anywhere, including tests:
// AppServiceProvider::boot() — development only
if (app()->isLocal()) {
DB::listen(function ($query) {
if ($query->time > 50) {
logger()->warning('Slow query', ['sql' => $query->sql, 'ms' => $query->time]);
}
});
}A page that runs 300 queries has an N+1 problem. A page that runs 5 queries but takes two seconds has an index or size problem. They need different fixes.
The N+1 problemLink to this section
This innocent loop runs one query for the orders and one more per order for its customer:
$orders = Order::latest()->take(50)->get(); // 1 query
foreach ($orders as $order) {
echo $order->customer->name; // +50 queries
}Eager loading turns that into two queries, however many orders there are:
$orders = Order::with('customer')->latest()->take(50)->get(); // 2 queriesNested relationships work the same way: Order::with(['customer', 'items.product']). If you only need a count, use withCount('items'), which adds a single subquery instead of loading every item.
Make lazy loading fail in developmentLink to this section
Laravel can throw an exception whenever a relationship is lazy-loaded, so N+1 bugs are caught while you write the code rather than in production:
// AppServiceProvider::boot()
Model::preventLazyLoading(! app()->isProduction());In production it stays off, so a missed eager load slows a page instead of breaking it. You can also log violations in production with Model::handleLazyLoadingViolationUsing() to find the ones that got through.
Read less dataLink to this section
Eloquent selects every column by default. On wide tables with text or JSON columns, that is a lot of data to read and hydrate into models.
// Only what the list page shows
$orders = Order::query()
->select(['id', 'customer_id', 'status', 'total', 'created_at'])
->with('customer:id,name') // the foreign key must be included
->latest()
->paginate(25);Let the database do arithmetic instead of PHP:
// Slow: loads every order into memory to add them up
$total = Order::where('status', 'paid')->get()->sum('total');
// Fast: one aggregate query
$total = Order::where('status', 'paid')->sum('total');For list pages with thousands of rows, prefer simplePaginate() or cursorPaginate() when you don't need the total page count. Standard paginate() runs an extra COUNT(*), which becomes expensive on big tables with complex filters.
Indexes: the biggest single winLink to this section
An index lets the database jump straight to the rows it needs instead of scanning the whole table. Add indexes in migrations for the columns you filter, join and sort by:
Schema::table('orders', function (Blueprint $table) {
$table->index(['status', 'created_at']); // WHERE status = ? ORDER BY created_at
$table->index('customer_id'); // joins and lookups by customer
});Column order matters in a composite index: put the column used for equality (status = 'paid') first and the range or sort column (created_at) second. foreignId()->constrained() adds an index on the foreign key for you on MySQL.
Check that the database actually uses the index:
DB::enableQueryLog();
Order::where('status', 'paid')->latest()->limit(25)->get();
dump(DB::select('EXPLAIN ' . DB::getQueryLog()[0]['query'], DB::getQueryLog()[0]['bindings']));In the EXPLAIN output, type: ALL with a large rows value means a full table scan. After adding the right index you should see ref or range and a much smaller row estimate.
Indexes speed up reads but slow down writes slightly and use disk space. Add them for real query patterns you have measured, not for every column.
Queries that can't use an indexLink to this section
Some query shapes defeat indexes no matter what you add:
where('email', 'like', '%@example.com')— a leading wildcard can't use a normal index. Store the domain in its own column, or use full-text search.whereRaw('DATE(created_at) = ?', [$day])— wrapping a column in a function hides it from the index. Use a range instead:whereBetween('created_at', [$day.' 00:00:00', $day.' 23:59:59']).orWhereacross different columns often prevents index use. Sometimes two queries combined withunionare faster.
Processing large tablesLink to this section
Loading 200,000 rows with get() will exhaust memory. Process them in batches:
// Batches of 500, safe even if rows are updated while you loop
Order::where('status', 'pending')
->chunkById(500, function ($orders) {
foreach ($orders as $order) {
$order->update(['status' => 'expired']);
}
});
// Or a lazy collection that streams results with low memory use
foreach (Order::where('exported', false)->lazyById(1000) as $order) {
ExportOrder::dispatch($order->id);
}Use chunkById() rather than chunk() when you modify the rows you are iterating over; offset-based chunking skips rows when the result set changes under it.
For simple bulk changes, skip models entirely and run one query: Order::where('status', 'pending')->where('created_at', '<', now()->subDays(30))->update(['status' => 'expired']);. Note that model events don't fire for mass updates, so don't use this when observers must run.
Caching expensive resultsLink to this section
Some queries stay expensive even when optimised: dashboards, reports, counts across large tables. Cache the result and invalidate it when the data changes:
$stats = Cache::remember('dashboard:order-stats', now()->addMinutes(10), function () {
return [
'today' => Order::whereDate('created_at', today())->count(),
'revenue' => Order::where('status', 'paid')->where('created_at', '>=', now()->startOfMonth())->sum('total'),
];
});Use Redis or Memcached as the cache store in production; the file driver is slow and the database driver adds load to the very database you're protecting. Clear specific keys when related data changes, for example in a model observer: Cache::forget('dashboard:order-stats').
Laravel 11 also offers Cache::flexible(), which serves a stale value while refreshing it in the background, so visitors never wait for the expensive query.
Don't forget the framework cachesLink to this section
In production, run these during every deployment:
php artisan config:cache
php artisan route:cache
php artisan view:cache
php artisan event:cache
composer install --no-dev --optimize-autoloaderTogether with OPcache enabled in PHP, they remove most of the framework's per-request overhead. If you schedule cache warming or report jobs, check their timing with the cron expression builder.
A worked example: a slow orders reportLink to this section
Here is how the steps above combine on a typical problem: an admin report listing the last 500 orders with customer names, item counts and totals takes eight seconds to load.
- Measure. Debugbar shows 1,502 queries. That's one for orders, then one customer query and two item queries per order: a classic N+1.
- Eager-load.
Order::with('customer:id,name')->withCount('items')->withSum('items', 'total')cuts it to four queries. The page now takes 1.4 seconds. - Select less. The orders table has a large
metaJSON column the report never shows. Selecting only the needed columns reduces memory use and transfer time from the database. - Index. The report filters by
statusand sorts bycreated_at.EXPLAINshows a full table scan; a composite index on(status, created_at)turns it into an index range scan. The page drops to around 300 ms. - Paginate. Nobody reads 500 rows at once.
cursorPaginate(50)keeps every page fast as the table grows, because it avoids largeOFFSETscans. - Cache only if still needed. At this point caching isn't necessary. If the same totals were shown on a busy dashboard, a short
Cache::remember()would be the final step.
The order matters. Caching first would have hidden 1,500 queries behind a cache that eventually expires, and the first visitor after each expiry would still wait eight seconds. Fix the queries, then cache what remains expensive.
Make regressions visibleLink to this section
Add Model::preventLazyLoading(! app()->isProduction()) in a service provider so a new N+1 throws an exception in development and tests, and log slow queries in production with DB::whenQueryingForLongerThan(). Performance problems then show up when the code is written, not months later when the table is large.
Common problemsLink to this section
- The page is fast locally but slow in production. Production has more data. Seed realistic volumes locally, or test against an anonymised copy.
- Eager loading made memory use worse. You loaded large relationships you didn't need. Constrain them:
with(['items' => fn ($q) => $q->select('id', 'order_id', 'qty')]). withCountis slow. It runs a subquery per row; make sure the related table has an index on the foreign key.- Cached data is stale. Every cache needs an invalidation plan. Prefer short TTLs plus targeted
forget()calls over long TTLs nobody remembers. - Dates look wrong in reports. Check the app and database time zones; convert values with the timestamp converter when comparing logs and database rows.
Frequently asked questionsLink to this section
Is Eloquent slower than the query builder?Link to this section
Hydrating models costs a little extra, which matters when you load tens of thousands of rows. For normal pages the difference is negligible; the number and shape of queries matter far more. For large exports or reports, use the query builder or toBase() to skip model hydration.
When should I denormalise?Link to this section
When a value is read far more often than it changes and computing it is expensive, for example an order's item count or a product's average rating. Store it in a column and update it in an observer or a queued job.
Should I use database views or stored procedures?Link to this section
Rarely in Laravel apps. They hide logic from the codebase and complicate migrations. Indexes, eager loading and caching solve most problems while keeping the logic in PHP where the team can test it.
How do I know if the database server itself is the bottleneck?Link to this section
Check the slow query log and server metrics such as CPU, buffer pool hit rate and connections. If queries are already indexed and few, the server may need more memory for the InnoDB buffer pool before it needs anything else.
New to Laravel? Start with Laravel for WordPress developers. If your Laravel app has become slow, send me the details.
Test yourself
3 quick questions on this lesson.
Finished this lesson?
Mark it complete to track your progress through the learning paths.