← Back to blog

The Database Index Audit I Run on Every Laravel SaaS Project

26 Oct 2026 · 4 min read

I got called in a few months ago to look at a SaaS app that was grinding to a halt every morning around 9am, right when all the customers logged in to check their dashboards. The developer who built it was convinced it was a queue problem, or maybe a caching problem. It was neither. It was a single query on an invoices table with about 2.1 million rows, filtering by tenant_id and status, sorted by due_date. No composite index. Just a lonely index on the primary key and an auto-generated one on the foreign key column.

That query was taking 4.2 seconds under load. After adding one index, it dropped to 11 milliseconds. One line in a migration. That's the kind of fix that makes you want to go and check every other project you've shipped.

So now I run a proper index audit on every Laravel SaaS project I touch, whether I built it or inherited it. Here's roughly what that looks like.

Start with EXPLAIN, not guesswork

I don't open the schema file first. I open the slow query log, or if Pulse is already installed, I look at whatever's sitting at the top of the slow queries list. Then I run EXPLAIN on it directly in a database client. If I see type: ALL in the output, that's a full table scan, and on anything over a few hundred thousand rows that's where your 4 second queries come from. MySQL will happily scan every row if you let it.

The rows column in the EXPLAIN output tells you roughly how many rows MySQL thinks it has to inspect to answer the query. On a well-indexed query against a large table, that number should be small, often in the hundreds or less. If it's matching the total row count of the table, something's wrong.

Foreign keys get an index automatically, but that's often not enough

One thing that trips people up: when you do $table->foreignId('tenant_id')->constrained() in a Laravel migration, you do get an index on tenant_id alone. That's fine for a simple where tenant_id = ? lookup. But almost no real query in a SaaS app looks like that. It's always where tenant_id = ? and status = ?, or where tenant_id = ? and created_at between ? and ?. A single-column index on tenant_id doesn't help much once you add a second condition, because MySQL still has to scan every row matching that tenant and filter the rest manually.

What you actually want is a composite index that matches the shape of your real queries:

Schema::table('invoices', function (Blueprint $table) {
    $table->index(['tenant_id', 'status', 'due_date']);
});

Column order matters here. MySQL uses what's called the leftmost prefix rule, so an index on (tenant_id, status, due_date) will speed up queries filtering on tenant_id alone, tenant_id and status, or all three. It won't help a query that filters on status and due_date without tenant_id. I've seen people add the columns in whatever order felt natural and wonder why the index isn't being used at all.

Don't just index everything

The temptation once you've seen one dramatic improvement is to start adding indexes to every column you can think of. I've done this myself on an early project, years ago, and regretted it. Every index you add has to be updated on every insert, update, and delete. On a table with heavy write traffic, like an activity_log or events table, five or six unnecessary indexes can genuinely slow down your write throughput and bloat your storage. I now ask myself, honestly, whether a column is used in a WHERE, JOIN, or ORDER BY clause on a query that actually runs often. If it's not, it probably doesn't need one.

The audit, in practice

For a typical client project, I'll run SHOW INDEX FROM table_name against the five or six busiest tables, cross reference that with the queries Pulse or the slow query log are flagging, and look for mismatches. Multi-tenant apps are the worst offenders, because almost every query has that tenant_id filter bolted on, and it's easy to forget it needs to lead every composite index you write.

It takes maybe half an hour on a mid-sized app. It's not glamorous work, and it won't get you a conference talk. But I'd rather spend thirty minutes checking indexes before a launch than get a panicked message from a client whose dashboard fell over the first morning real customers showed up.