A slow Laravel application is rarely fixed with a bigger server. In most projects I review, the time goes into a handful of poorly written queries, work that could run in the background, or caches nobody has switched on. These are the ten changes that usually make the biggest difference, ordered from cheapest to most structural.
1. Measure before you optimise
Optimising without data is guessing. Before touching any code, find out where the time really goes:
- Locally: Laravel Debugbar or Telescope to see how many queries each request runs and how long each takes.
- In production: Laravel Pulse to spot slow routes, slow queries and stuck queues.
- In the database: MySQL’s slow query log with a sensible threshold (for example, 500 ms).
Write down the response time and query count of your three or four busiest routes. That is your baseline.
2. Get rid of N+1 queries
This is the most common problem: looping over a collection and touching a relationship on each pass fires one query per item. Load relationships up front with with():
// Before: 1 query for orders + 1 per customer
$orders = Order::latest()->take(50)->get();
// After: 2 queries in total
$orders = Order::with('customer')->latest()->take(50)->get();
To stop it from happening again, make Laravel warn you about lazy loading in development:
// app/Providers/AppServiceProvider.php
use Illuminate\Database\Eloquent\Model;
public function boot(): void
{
Model::preventLazyLoading(! app()->isProduction());
}
3. Select only the columns you need and index properly
A select * on tables with long text or JSON fields moves far more data than needed. Limit columns with select() and check that the columns you use in where, orderBy and relationship keys are indexed. Running EXPLAIN on the slow query is usually enough to see whether MySQL is scanning the whole table.
4. Paginate and process in batches
Never load a full table into memory. For listings, use paginate() or cursorPaginate(). For bulk jobs (exports, recalculations, imports), work in chunks:
Order::where('status', 'pending')
->chunkById(500, function ($orders) {
foreach ($orders as $order) {
$order->recalculateTotals();
}
});
5. Move heavy work to queues
Sending emails, generating PDFs, calling external APIs or processing images should not keep the user waiting. Implement ShouldQueue on those jobs, use Redis as the queue driver and supervise workers with Horizon. The request responds immediately and the work runs in the background, with retries if something fails.
6. Cache data that rarely changes
Catalogues, settings, counters and expensive query results are clear candidates. Cache::remember() covers most cases, and Cache::flexible() lets you serve the previous value while it is recalculated in the background:
$categories = Cache::remember('categories.menu', now()->addHour(), function () {
return Category::orderBy('position')->get(['id', 'name', 'slug']);
});
The key is not caching a lot, but knowing when to invalidate: tie each key to the model event that changes it.
7. Enable framework caches on every deploy
Laravel can precompile configuration, routes, views and events. In production, add this step to your deploy script:
composer install --no-dev --optimize-autoloader
php artisan optimize
Remember that, with cached configuration, env() should only be used inside config/ files.
8. Tune PHP and the server
OPcache, enabled and properly sized, is a must in production. From there, size PHP-FPM processes to the available memory, enable compression (gzip or Brotli) in Nginx and serve Vite-built assets with long cache headers. For high-traffic applications, Laravel Octane keeps the app in memory between requests and cuts boot time dramatically.
9. Return lighter responses
In APIs, use API Resources to return only the fields the client needs, and avoid serialising full relationships by default. In Blade views, do not run queries from the template: prepare the data in the controller or a view model.
10. Protect improvements with tests
An optimisation without a test is lost by the next sprint. Add tests that fail if a critical route starts firing extra queries again:
public function test_orders_listing_has_no_n_plus_1(): void
{
Order::factory()->count(30)->create();
$this->expectsDatabaseQueryCount(3);
$this->actingAs(User::factory()->create())
->get('/orders')
->assertOk();
}
And after each deploy, compare timings with your baseline.
Where to start
If you can only do one thing this week: measure your busiest routes, remove any N+1 queries you find and move the slowest process to a queue. In most applications, those three changes are already noticeable.
If your Laravel application is slow and you are not sure where to start, I can run a performance review of your Laravel project with a prioritised report. You can also see these practices applied in real cases such as the B2B customer portal or the warehouse task tracking app.