Skip to main content
GullySystem
A practical guide

Why a Laravel app slows down as records grow

Ganesh HSStrategy and Technology, GullySystem
Practical advice · 5 min read

In this guide 6 sections
The short answer

A Laravel app slows as tables grow because code that was cheap on a few thousand rows turns costly on millions. The usual culprits are N+1 queries from Eloquent relations, filters on unindexed columns, reports built inside web requests, stopped queue workers and uncached config and routes. Query logs show which one applies.

Log the queries before you change anything

Guessing wastes days. Turn on query logging for one slow page, or install Laravel Debugbar or Telescope in a staging copy loaded with a recent database dump. Open the page and read the list.

You are looking for two things: a page that fires hundreds of near identical statements, and a single statement that takes seconds. Each points to a different fix. Copy the slow statement into EXPLAIN in MySQL and read the type, key and rows columns before anyone touches a controller or a migration.

  • Count of queries per page load
  • The slowest single statement and its EXPLAIN output
  • Rows examined versus rows returned
  • Time spent in PHP versus time spent in the database

N+1 queries hide in Eloquent relations

A list of orders showing a customer name on every row looks harmless, yet Eloquent runs one query for the orders and one more per row. Fifty rows is fine. Fifty thousand is not.

The fix is eager loading. Add with(['customer','items.product']) to the query so Laravel fetches the related rows in a few batched statements instead of one statement for every single order line. Setting Model::preventLazyLoading() in local and staging makes the offending page throw an error the day someone writes it, long before a customer waits on it.

Unindexed filters and unbounded lists

Search boxes, status filters and date ranges usually end in a WHERE clause on a column nobody indexed, so MySQL reads the whole table for every click. EXPLAIN shows this as type ALL.

Add a composite index that matches the filter and the sort together, such as status plus created_at, in a migration that runs during a quiet hour. Also stop loading every row. Use paginate() or cursor pagination, and avoid Model::all() inside controllers, which pulls the full table into PHP memory and can exhaust the worker before the page renders.

  • Index the columns used in WHERE, JOIN and ORDER BY
  • Prefer a composite index over several single ones
  • Use select() to fetch only the columns the page shows
  • Replace leading wildcard LIKE searches with full text search

Reports and exports do not belong in a web request

A month-end sales report that loops over every invoice will work in the first year, but by the third the browser spins and PHP-FPM hits its time limit. Meanwhile the stuck worker holds its slot, so every other user queues behind a report that may never finish, while the invoice table keeps growing every day.

Move the work to a queued job. The user clicks Export, a job builds the CSV or PDF in chunks with chunkById(), and the file appears in a downloads list or arrives by email. Nightly summary tables beat recalculating totals.

Check queue workers, caches and the server settings

If jobs exist but nothing happens, the queue worker is not running, so run it under Supervisor or systemd and let it restart after a crash or a deploy. Check failed_jobs for repeated errors. A stuck worker makes emails and imports look like a slow app, so confirm it runs before you spend a week rewriting queries that were never the problem.

On the server, run php artisan config:cache, route:cache and view:cache during every deploy, and confirm OPcache is switched on for PHP-FPM. Move cache and sessions to Redis once the file driver grows large and every request spends time reading many small files from disk. Then rerun the logged page and compare query count, slowest statement and load time against the first reading you wrote down.

Laravel slow page diagnosis sheet

Slow pages are listed one by one. Columns hold the URL, query count, slowest statement and EXPLAIN type, followed by the suspected cause, the fix tried and the load time before and after the change. A footer lists the artisan cache commands to tick off at each deploy, from config:cache to view:cache.

Open a blank worksheet to print

Questions owners ask

How do I find N+1 queries in Laravel?

Open the page with Debugbar or Telescope and look for the same SELECT repeated with a different id, the mark of a relation loaded row by row. Calling Model::preventLazyLoading() in staging makes lazy loaded relations throw an error, so the developer catches them while coding instead of a customer catching them in production.

Will adding an index slow down writes?

A little, because MySQL updates each index on every insert and update, so a table with eight indexes pays eight small costs per write. Reads dominate order and customer tables. Add indexes for the filters people actually use on orders, invoices and customers, and skip every column that nobody ever searches or sorts by.

Should we upgrade the server first?

Usually not. A bigger server hides an N+1 query for a few months, and then the pages slow again as the tables keep growing. Fix the queries first. Size the server only on measured load.

Can queues fix a slow report?

Queues move the wait out of the browser, which stops timeouts and frees web workers, though the report must still be efficient. Chunk the query with chunkById() and keep nightly summary tables for totals so the export stays quick as the invoice table grows.

Browse all guidesBack to top ↑
Next step

Have a specific situation to work through?

This article covers the general case. Tell us what you’re actually dealing with and we’ll respond directly.

Share Your Requirements