Let’s be honest, we’ve all been there. You build a feature, it works great on your local machine with a handful of records, but then it hits production. Suddenly, what was a snappy page load becomes a frustrating crawl. More often than not, the culprit is a slow database query. For years, I, like many developers, treated database indexing as a bit of black magic. You add an index here or there and hope for the best.
But performance isn’t magic. It’s engineering.
Recently, I was diving into the book Indexing Beyond the Basics, and it really crystallized the core principles for me. The theory is universal, but the real value comes from translating it into our day-to-day work. For me, that’s the world of Laravel and Eloquent. This post is my attempt to do just that: to bridge the gap between deep database theory and the practical, get-your-hands-dirty reality of building fast Laravel applications.
The “Why”: It’s All About Sorting
Before we write a single line of code, let’s grasp the one fundamental idea that makes indexes work. Forget complex data structures like B+ Trees for a moment. The book puts it perfectly: a database index is like the index in the back of a textbook.
If you want to find a specific topic, you don’t read the whole book cover-to-cover. That would be insane, right? Instead, you flip to the alphabetized index at the back, find your topic, and jump directly to the right page.
A database index does the exact same thing for a database column. It’s a separate, sorted list of the data. When you run a query like User::where('email', 'rama@qisthi.dev')->first(), the database doesn’t perform a “full table scan” (reading every single user). It uses the sorted index on the email column to find the record’s location almost instantly.
The core principle is simple: Indexes make read queries fast by avoiding slow, full-table scans.
The “How”: Crafting Indexes with Laravel Migrations
In Laravel, we don’t write raw CREATE INDEX statements. We define our schema’s evolution through migrations, which is perfect for keeping our indexing strategy in version control.
Let’s start with a new migration to add an index.
php artisan make:migration add_indexes_to_users_table
Inside that migration, we can add different kinds of indexes.
Single-Column Indexes
This is your bread and butter. If you’re frequently querying a single column, you give it an index. Let’s say we often look up users by their status.
// inside up() method of the migration
Schema::table('users', function (Blueprint $table) {
$table->index('status');
});
Simple. Now, User::where('status', 'active')->get() is fast.
The Real Powerhouse: Multi-Column Indexes
Here’s where things get interesting. What about queries that filter by multiple columns?
User::where('status', 'active')->where('country_code', 'ID')->get();
Your first instinct might be to add two separate indexes. Don’t do it! That’s inefficient. What you need is a single multi-column (or composite) index. And with these, there is one golden rule: order is everything.
Think of a multi-column index like a phone book sorted by Last Name, then First Name. Finding “Ramadhani, Qisthi” is easy. But finding all the people named “Qisthi”? Good luck. You’d have to scan the whole book.
The database reads multi-column indexes from left to right. So, for the query above, our index should follow the same order.
Schema::table('users', function (Blueprint $table) {
// Correct: The order matches our common query pattern.
$table->index(['status', 'country_code']);
});
Now, the database can instantly jump to the “active” block, and within that already-filtered block, it can quickly find the users in “ID”. This “left-to-right” rule is the most critical concept for getting multi-column indexes right.
Writing Queries That Actually Use Your Indexes
Creating the index is only half the battle. You have to write your Eloquent queries in a way that can take advantage of them.
Let’s stick with our ['status', 'country_code'] index. What happens if we run this query?
// Find all users from Indonesia, regardless of status.
User::where('country_code', 'ID')->get();
Because our index is sorted by status first (the “Last Name” in our phone book analogy), the database can’t use it to efficiently find country_codes. It’s like trying to find “Qisthi” in the phone book—the entries are scattered everywhere. The query will work, but it will likely trigger a slow table scan. You must satisfy the index from left to right, without skipping a column.
Don’t Forget orderBy!
Indexes aren’t just for WHERE clauses. They can also supercharge your sorting. When you ask the database to sort results, it often has to perform a costly “filesort” operation in memory or on disk. But if an index already provides the data in the correct order? Game-changer.
Consider this common query:
// Get the newest active users
User::where('status', 'active')->orderBy('created_at', 'desc')->get();
We can optimize both the WHERE and the ORDER BY in a single index. Just add the sorted column to the end.
Schema::table('users', function (Blueprint $table) {
$table->index(['status', 'created_at']);
});
Now, the database finds all “active” users and reads them in an order that is already sorted by created_at. No extra sorting step needed. Beautiful.
Playing Detective: Why Isn’t My Index Being Used?
You’ve built the “perfect” index, but your query is still slow. The first step in debugging is to stop guessing and see what Laravel is actually doing. The toSql() method is your best friend.
$sql = User::where('status', 'active')
->orderBy('created_at', 'desc')
->toSql();
dd($sql); // 'dump and die' to see the output
This gives you the raw SQL query. You can take this query to your database client of choice (like TablePlus or DBeaver) and prepend it with EXPLAIN. The output of EXPLAIN will tell you exactly how the database plans to execute the query, including which indexes (if any) it decided to use.
One of the most common reasons an index is ignored is when you apply a function to a column in the WHERE clause. For example, WHERE YEAR(created_at) = 2025. This prevents a standard index on created_at from being used. Which leads us to an advanced technique…
Advanced Moves: Functional Indexes
So what do you do when you have to query on the result of a function? You index the function’s result itself!
Laravel’s schema builder doesn’t have a dedicated method for this, but it gives us a powerful escape hatch: DB::raw(). If you need to find users by registration month, you can create a functional index like this:
use Illuminate\Support\Facades\DB;
// ... inside the up() method
// Note: Syntax can vary slightly between databases (e.g., MySQL vs. PostgreSQL)
DB::statement('CREATE INDEX users_created_at_month_index ON users ((MONTH(created_at)))');
Now, the database has an index on the output of MONTH(created_at), and a query like User::whereMonth('created_at', 8)->get() will be lightning fast.
Your Toolkit is Ready
And there you have it. We’ve gone from the “why” to the “how” and into the advanced “what-ifs.” This isn’t just theory; it’s a practical toolkit for writing high-performance Laravel applications.
- Understand the Why: Indexes are sorted lists to avoid full table scans.
- Build Them Right: Use migrations. Remember the “left-to-right” rule for multi-column indexes.
- Query Them Smartly: Your
WHEREandORDER BYclauses must align with your index structure. - Debug with Confidence: Use
toSql()andEXPLAINto see what’s really happening. - Level Up: Use
DB::raw()for powerful functional indexes when you need them.
Mastering these concepts will fundamentally change how you approach database performance. Go forth and make your apps fly!
What are your go-to indexing tricks in Laravel? I’d love to hear your thoughts and experiences. Find me on X/Twitter @ramageek or check out my open-source work on GitHub @qisthidev.