Step-by-step
-
1
Identify the Slow Query First
You cannot fix what you cannot find. Three places to look, in order of precision:
- MySQL slow query log — the most reliable source. Enable it and set a threshold; MySQL writes every query that exceeds it.
- Laravel Telescope / Debugbar — shows all queries for a request, their duration, and the call stack that triggered them. Invaluable in development.
- DB::listen() in AppServiceProvider — log queries to a file in any environment, no extra package required.
sql-- Enable slow query log (add to my.cnf or run at runtime) SET GLOBAL slow_query_log = 'ON'; SET GLOBAL long_query_time = 1; -- log queries over 1 second SET GLOBAL slow_query_log_file = '/var/log/mysql/slow.log'; -- View the log -- tail -f /var/log/mysql/slow.log -- Or use mysqldumpslow to summarize it -- mysqldumpslow -s t -t 10 /var/log/mysql/slow.log -
2
Listen to Queries in Laravel
For development, register a query listener in
AppServiceProvider::boot(). This logs every query with its bindings and execution time — no external tools needed. Remove or gate it with anAPP_DEBUGcheck before deploying.php<?php // app/Providers/AppServiceProvider.php use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Log; public function boot(): void { if (config('app.debug')) { DB::listen(function ($query) { if ($query->time > 100) { // only log queries over 100ms Log::channel('daily')->warning('Slow query', [ 'sql' => $query->sql, 'bindings' => $query->bindings, 'time_ms' => $query->time, ]); } }); } } -
3
Run EXPLAIN on the Slow Query
Prepend
EXPLAINto the SELECT and read the output. The columns that matter most:- type — the join/access type. Worst to best:
ALL(full scan) →index→range→ref→eq_ref→const. You wantrefor better. - key — the index actually used. NULL means no index was used.
- rows — the estimated number of rows MySQL will examine. Multiply this across all rows in the output to get the total work.
- Extra — watch for
Using filesortandUsing temporary— both are expensive.
sqlEXPLAIN SELECT o.id, o.created_at, u.email FROM orders o JOIN users u ON u.id = o.user_id WHERE o.status = 'pending' ORDER BY o.created_at DESC LIMIT 20; -- Example bad output: -- +----+------+--------+------+------+------+-------------+ -- | id | type | key | ref | rows | filt | Extra | -- +----+------+--------+------+------+------+-------------+ -- | 1 | ALL | NULL | NULL | 980k | 0.50 | Using where | -- +----+------+--------+------+------+------+-------------+ -- type=ALL + key=NULL = full table scan on 980,000 rows - type — the join/access type. Worst to best:
-
4
Add the Missing Index
The fix for a full table scan on a WHERE/ORDER BY is almost always an index. For the query above, a composite index on
(status, created_at)covers both the filter and the sort in a single index scan. Column order matters: put the equality column first (status), then the range/sort column (created_at).sql-- Add a composite index that covers the WHERE and ORDER BY ALTER TABLE orders ADD INDEX idx_orders_status_created (status, created_at); -- Run EXPLAIN again to verify EXPLAIN SELECT o.id, o.created_at, u.email FROM orders o JOIN users u ON u.id = o.user_id WHERE o.status = 'pending' ORDER BY o.created_at DESC LIMIT 20; -- Expected improved output: -- +----+------+---------------------------+-------+------+ -- | id | type | key | rows | Extra| -- +----+------+---------------------------+-------+------+ -- | 1 | ref | idx_orders_status_created | 4200 | | -- +----+------+---------------------------+-------+------+ -
5
Stop Wrapping Indexed Columns in Functions
Functions applied to a column in a WHERE clause prevent index usage entirely. MySQL cannot use an index on
created_atif the condition isDATE(created_at) = '2024-01-15'— it has to computeDATE()for every single row.The fix is always to rewrite the condition as a range against the raw column value.
sql-- Bad: function wraps the indexed column → full scan SELECT * FROM orders WHERE DATE(created_at) = '2024-01-15'; -- Bad: same problem with YEAR/MONTH SELECT * FROM orders WHERE YEAR(created_at) = 2024 AND MONTH(created_at) = 1; -- Good: range query — MySQL can use the index SELECT * FROM orders WHERE created_at >= '2024-01-15 00:00:00' AND created_at < '2024-01-16 00:00:00'; -- Good: LOWER() on a column kills the index too -- Bad: WHERE LOWER(email) = 'user@example.com' -- Good: store emails already lowercased, or use a case-insensitive collation -
6
Use EXPLAIN ANALYZE for Actual Timings
MySQL 8.0+ supports
EXPLAIN ANALYZE, which actually executes the query and reports real row counts and timings alongside the estimates. This is the most accurate tool for diagnosing slow queries — the estimates in plainEXPLAINare sometimes off by orders of magnitude on skewed data.sql-- MySQL 8.0+ only — actually runs the query EXPLAIN ANALYZE SELECT o.id, u.email FROM orders o JOIN users u ON u.id = o.user_id WHERE o.status = 'pending' ORDER BY o.created_at DESC LIMIT 20; -- Output shows: -- -> Limit: 20 row(s) (actual time=0.318..0.319 rows=20 loops=1) -- -> Index lookup on o using idx_orders_status_created -- (status='pending') (cost=1820 rows=4200) -- (actual time=0.089..0.304 rows=20 loops=1) -
7
Fix N+1 Queries at the Application Layer
The N+1 problem is a query-per-row pattern: you fetch 50 posts, then the loop fires 50 separate queries to get each post's author. It does not show up in a single slow query — it shows up as 51 fast queries that collectively take 2 seconds.
In Eloquent the fix is
with(). In plain SQL the fix is a JOIN. The rule: never query inside a loop.php<?php // Bad: N+1 — fires one query per post $posts = Post::all(); foreach ($posts as $post) { echo $post->author->name; // SELECT * FROM users WHERE id = ? × N } // Good: eager load — 2 queries total regardless of N $posts = Post::with('author')->get(); foreach ($posts as $post) { echo $post->author->name; } // Also good: nested eager loading $posts = Post::with(['author', 'comments.author'])->paginate(20); // Laravel Debugbar or Telescope will flag N+1s visually — // enable them in development and look for repeated identical queries. -
8
Benchmark with Realistic Data
A query that is fast on 1,000 rows is not necessarily fast on 1,000,000. Always benchmark with realistic data volume before declaring a fix done. Use
EXPLAINbefore and after, capture actual query time withEXPLAIN ANALYZEor a timer, and keep both results so you can show the improvement.sql-- Benchmark: compare before and after -- 1. Record baseline SET @start = NOW(6); SELECT COUNT(*) FROM orders WHERE status = 'pending' ORDER BY created_at DESC; SELECT TIMESTAMPDIFF(MICROSECOND, @start, NOW(6)) / 1000 AS ms; -- 2. Add the index ALTER TABLE orders ADD INDEX idx_orders_status_created (status, created_at); -- 3. Run again and compare SET @start = NOW(6); SELECT COUNT(*) FROM orders WHERE status = 'pending' ORDER BY created_at DESC; SELECT TIMESTAMPDIFF(MICROSECOND, @start, NOW(6)) / 1000 AS ms; -- Also check SHOW STATUS LIKE 'Handler_read%' before and after -- to see how many rows were actually read by the storage engine.
Tips & gotchas
- <code>EXPLAIN</code> shows the query plan before execution; <code>EXPLAIN ANALYZE</code> (MySQL 8+) shows actual execution — always prefer <code>ANALYZE</code> when you need the ground truth.
- The <code>rows</code> column in EXPLAIN is an estimate. On tables with skewed data distributions it can be wildly inaccurate — run <code>ANALYZE TABLE orders</code> to refresh the statistics first.
- Adding an index speeds reads but slows writes (INSERT/UPDATE/DELETE). On high-write tables, every index has a cost — only keep the ones that are actually used.
- Use <code>SHOW INDEX FROM table_name</code> to see all existing indexes and their cardinality before adding a duplicate.
- The MySQL <code>performance_schema</code> and <code>sys</code> schema contain views like <code>sys.statements_with_full_table_scans</code> that surface slow patterns across all traffic, not just your current session.
Wrapping up
Slow queries are always diagnosable — EXPLAIN tells you exactly what the optimizer is doing and why. Start by finding the query, understand the access type, add the minimum index that covers the WHERE and ORDER BY, then verify with real data. The N+1 problem is the other half of database performance; fix it at the Eloquent layer before it multiplies.