The #1 database performance killer (and how to fix it).
A missing index is responsible for 90% of database performance problems in production Laravel apps. Developers add indexes in development (where they have 100 rows) and forget them in production (where they have 1,000,000 rows). The result: queries that take 5 seconds instead of 0.001 seconds.
When you query WHERE email = 'user@example.com' and there's NO index on email, the database:
1,000,000 rows examined vs 20 rows examined = 50,000x faster. An index transforms a 5-second query into a 0.0001-second query.
| Table Size (rows) | Without Index (Full Scan) | With Index (B-Tree) | Difference |
|---|---|---|---|
| 1,000 rows | 0.005 sec | 0.0005 sec | 10x slower |
| 10,000 rows | 0.05 sec | 0.0005 sec | 100x slower |
| 100,000 rows | 0.5 sec | 0.0005 sec | 1,000x slower |
| 1,000,000 rows | 5 sec | 0.0005 sec | 10,000x slower |
| 10,000,000 rows | 50 sec | 0.001 sec | 50,000x slower |
In development with 1,000 rows, you don't notice missing indexes. Everything is fast. In production with 1,000,000 rows, the same query takes 5 seconds. Your users complain. Your server times out. All because you forgot one CREATE INDEX command.
Every column in a WHERE clause, JOIN condition, or ORDER BY should have an index.
-- Single column index
CREATE INDEX idx_users_email ON users(email);
-- Composite index (multiple columns)
CREATE INDEX idx_orders_user_status ON orders(user_id, status);
-- Unique index (prevents duplicates + faster lookups)
CREATE UNIQUE INDEX idx_users_email_unique ON users(email);
-- Full text index (for searching text)
CREATE FULLTEXT INDEX idx_posts_content ON posts(content);
-- In Laravel migrations:
Schema::table('users', function (Blueprint $table) {
$table->index('email'); // Simple index
$table->unique('email'); // Unique index
$table->index(['user_id', 'status']); // Composite index
$table->fullText('content'); // Full text index
});
EXPLAIN tells you exactly how MySQL will execute your query — before it runs.
Problem: No index on email. MySQL scans every single row.
Solution: Added INDEX idx_users_email ON users(email). MySQL now uses binary search.
| Column | Bad Value | Good Value | Meaning |
|---|---|---|---|
| type | ALL, index | ref, eq_ref, range, const | ALL = full table scan (disaster). ref/range = index used (good). |
| key | NULL | index_name | NULL means no index used. Any value means index was used. |
| rows | Large number (100k+) | Small number (1-100) | Estimated rows examined. Lower is better. |
| Extra | "Using filesort", "Using temporary" | "Using index", NULL | Filesort/temporary = bad (needs index optimization). |
A composite index on (user_id, created_at) is NOT the same as (created_at, user_id). The order determines which queries can use the index.
MySQL can use a composite index only when the query filters on the leftmost column(s). Put the most selective column first.
| Index | Query | Can use index? |
|---|---|---|
(user_id, status, created_at)
| WHERE user_id = 123
| ✅ Yes (uses leftmost) |
(user_id, status, created_at)
| WHERE user_id = 123 AND status = 'active'
| ✅ Yes (uses first two) |
(user_id, status, created_at)
| WHERE user_id = 123 AND created_at > '2024-01-01'
| ✅ Partial (skips status, less efficient) |
(user_id, status, created_at)
| WHERE status = 'active'
| ❌ No (user_id is missing) |
(user_id, status, created_at)
| WHERE created_at > '2024-01-01'
| ❌ No (leftmost missing) |
| Benefits (Reads) | Costs (Writes) | |
|---|---|---|
| SELECT queries | 10-50,000x faster | — |
| INSERT | — | 10-50% slower (must update indexes) |
| UPDATE | — | Slower if indexed columns change |
| DELETE | — | Must update indexes |
| JOIN operations | Much faster with indexes | — |
| Disk space | — | 5-10% additional per index |
Don't index everything. Each index makes writes slower and uses disk space. Index only the columns you actually query on.
For a typical Laravel app with 90% reads and 10% writes, indexes are almost always worth it.
# Enable slow query log (my.cnf)
slow_query_log = 1
slow_query_log_file = /var/log/mysql/slow.log
long_query_time = 2 # Log queries taking > 2 seconds
Then analyze: pt-query-digest /var/log/mysql/slow.log
Install barryvdh/laravel-debugbar. It shows:
-- MySQL can suggest missing indexes
SELECT * FROM users WHERE email = 'john@example.com'\G
-- Look at the 'Extra' column for index suggestions
| Query Type | Without Index | With Index | How to Identify |
|---|---|---|---|
Equality (WHERE col = value)
| Full table scan | Index lookup | Simple index on column |
Range (WHERE col > value)
| Full table scan | Index range scan | Index on column (range works) |
JOIN (ON t1.col = t2.col)
| Nested loops / full scan | Index join | Index on both sides of JOIN |
| ORDER BY | Filesort (temp table) | Index scan | Index on ORDER BY column(s) |
| GROUP BY | Temporary table | Index group by | Index on GROUP BY column(s) |
Topic 10: Read/Write Splitting (Master/Replica) — How to scale your database horizontally. Send writes to master, reads to replicas. Double your capacity without changing a line of application code.