12 min read

Database Indexing in Laravel: Boost MySQL Performance with Smart Indexes

Master database indexing in Laravel to optimize MySQL query performance. Learn when and how to create indexes using migrations with real-world benchmarks.

Laravel MySQL Database Optimization Laravel Migrations Query Performance
Database Indexing in Laravel: Boost MySQL Performance with Smart Indexes

I learned about database indexes the hard way, after my client's dashboard took 8 seconds to load their user list. Eight. Whole. Seconds.

The table had 500,000 users, and I was running queries with WHERE clauses on unindexed columns. The moment I added proper indexes, response time dropped to 180ms. That's a 97% improvement from one migration file.

Here's the thing about database indexing in Laravel: most developers either ignore it completely or add indexes everywhere "just in case." Both approaches will hurt you. The first leads to slow queries as your data grows. The second creates unnecessary overhead on writes and wastes disk space.

In this guide, I'll show you exactly when to add indexes, how to create them in Laravel migrations, and the performance impact you can expect. Plus, I'll share the mistakes that cost me hours of debugging so you don't repeat them.

What Are Database Indexes and Why Should You Care?

Think of a database index like the index at the back of a textbook. Without it, you'd flip through every single page to find a topic. With an index, you jump straight to the relevant pages.

Database indexes work the same way. When you run a query like WHERE email = 'hafiz@example.com', MySQL without an index performs a full table scan checking every single row until it finds a match. On a table with 10,000 rows, that's fine. On a table with 1 million rows? Your users are waiting.

An index creates a separate data structure (usually a B-tree) that MySQL uses to locate rows instantly. Instead of scanning 1 million rows, it might check only 3-4 index nodes to find your data.

But indexes aren't free. Every time you INSERT, UPDATE, or DELETE a row, MySQL must also update all related indexes. That's why blindly adding indexes everywhere can actually slow down your application.

When to Add Database Indexes in Laravel

After building StudyLab (which processes thousands of PDF uploads) and ReplyGenius (which searches through massive social media datasets), I've developed a simple rule: index columns you filter, sort, or join on frequently.

Here are specific scenarios where indexes make a massive difference:

Foreign Keys If you're running queries like WHERE user_id = 123, you need an index on user_id. Laravel doesn't automatically index foreign keys (except when using foreignId() or foreign() constraint helpers), so you'll often need to add them manually.

Email and Username Columns Any column used for authentication or lookups needs an index. I once worked on a project where login queries took 2+ seconds because the email column wasn't indexed.

Status and Type Fields Columns like status, is_active, or type that you filter frequently should be indexed. However, if the column has very low cardinality (only 2-3 distinct values), indexes become less effective.

Created_at and Updated_at If you're building dashboards or reports that sort by date or filter date ranges, index these columns. They're used constantly but rarely indexed.

Search Fields Columns you search with LIKE queries benefit from indexes, but only for prefix searches (LIKE 'term%'). Wildcard searches (LIKE '%term%') can't use indexes effectively and need full-text indexing instead.

Don't index:

  • Columns you never filter or sort by
  • Boolean columns with 50/50 distribution (indexes provide minimal benefit)
  • Small tables (under 1,000 rows where full scans are already fast)
  • Columns that change frequently but are rarely queried

How to Create Database Indexes in Laravel Migrations

Laravel makes index creation straightforward through migration methods. Let me show you the most common patterns I use in production.

Single Column Indexes

The simplest case, indexing one column:

Schema::create('users', function (Blueprint $table) {
    $table->id();
    $table->string('email')->unique(); // Creates unique index automatically
    $table->string('username')->index(); // Regular index
    $table->enum('status', ['active', 'inactive', 'suspended'])->index();
    $table->timestamps();
});

When you call unique(), Laravel creates a unique index, which also speeds up queries. The index() method creates a regular non-unique index.

Composite Indexes (Multiple Columns)

Sometimes you need to index multiple columns together. This is crucial for queries that filter on multiple conditions:

Schema::table('orders', function (Blueprint $table) {
    // Query: WHERE user_id = 123 AND status = 'pending'
    $table->index(['user_id', 'status'], 'idx_user_status');
});

Column order matters here. MySQL reads indexes left-to-right, so this composite index helps:

  • Queries filtering on user_id alone
  • Queries filtering on both user_id AND status

But it won't help queries that only filter on status. For that, you'd need a separate index on status.

I learned this the hard way when building ReplyGenius. I created an index on ['status', 'user_id'], but my main query filtered by user_id first. The index was useless until I reversed the column order.

Foreign Key Indexes

Laravel's foreignId() method automatically creates an index:

Schema::create('posts', function (Blueprint $table) {
    $table->id();
    $table->foreignId('user_id')->constrained()->onDelete('cascade');
    // Automatically creates index on user_id
});

But if you're using older Laravel versions or defining foreign keys manually:

Schema::table('posts', function (Blueprint $table) {
    $table->unsignedBigInteger('user_id');
    $table->foreign('user_id')->references('id')->on('users');
    $table->index('user_id'); // Manually add index
});

Adding Indexes to Existing Tables

You'll often realize you need an index after your table is already in production. Create a new migration:

public function up()
{
    Schema::table('users', function (Blueprint $table) {
        $table->index('email', 'idx_users_email');
    });
}

public function down()
{
    Schema::table('users', function (Blueprint $table) {
        $table->dropIndex('idx_users_email');
    });
}

Always name your indexes explicitly using the second parameter. Laravel auto-generates names, but they're often too long and cryptic. I use a convention: idx_tablename_columns.

Full-Text Indexes for Search

For search functionality, regular indexes don't cut it. Use full-text indexes:

Schema::create('articles', function (Blueprint $table) {
    $table->id();
    $table->string('title');
    $table->text('content');
    $table->timestamps();
    
    // Full-text index on title and content
    DB::statement('ALTER TABLE articles ADD FULLTEXT idx_articles_search (title, content)');
});

Then query with whereRaw():

$articles = Article::whereRaw(
    'MATCH(title, content) AGAINST(? IN NATURAL LANGUAGE MODE)',
    [$searchTerm]
)->get();

Full-text search is way faster than LIKE '%term%' queries, especially on large text fields. I use this in StudyLab for searching through quiz questions and explanations.

Real-World Performance Benchmarks

Let me show you actual numbers from projects I've worked on. These aren't synthetic benchmarks, they're production data.

User Lookup Query (500,000 rows):

// Before index on email column
User::where('email', 'test@example.com')->first();
// Query time: 3,200ms (full table scan)

// After adding index
Schema::table('users', function (Blueprint $table) {
    $table->index('email');
});
// Query time: 12ms (97% improvement)

Dashboard Analytics Query (1.2 million order rows):

// Before composite index
Order::where('user_id', 123)
     ->where('status', 'completed')
     ->whereBetween('created_at', [$start, $end])
     ->count();
// Query time: 5,800ms

// After adding composite index
Schema::table('orders', function (Blueprint $table) {
    $table->index(['user_id', 'status', 'created_at']);
});
// Query time: 85ms (98% improvement)

Sorting Without Index (800,000 product rows):

// Before index on created_at
Product::orderBy('created_at', 'desc')->paginate(20);
// Query time: 2,100ms

// After index
Schema::table('products', function (Blueprint $table) {
    $table->index('created_at');
});
// Query time: 45ms (98% improvement)

The pattern is clear: indexes can reduce query time by 95-99% on large tables. But remember, these are read queries. Let's talk about the write performance cost.

Insert Performance Impact:

On the orders table with 5 indexes, inserting 10,000 rows:

  • Without indexes: 8.2 seconds
  • With 5 indexes: 14.7 seconds

That's a 79% slowdown on bulk inserts. This is why I'm selective about indexing, every index adds overhead to writes.

Step-by-Step: Optimizing a Slow Query with Indexes

Let's walk through a real scenario. You've got a slow dashboard that shows recent activity for logged-in users.

Step 1: Identify the Slow Query

Install Laravel Debugbar or use Telescope:

// In your controller
$activities = Activity::where('user_id', auth()->id())
    ->where('type', 'purchase')
    ->orderBy('created_at', 'desc')
    ->limit(50)
    ->get();

Debugbar shows this query takes 4.2 seconds on 600,000 rows.

Step 2: Check Current Indexes

Run this SQL to see existing indexes:

SHOW INDEXES FROM activities;

If you only see the primary key (id), you've got a problem.

Step 3: Analyze the Query

This query filters on user_id and type, then sorts by created_at. You need a composite index covering all three.

Step 4: Create the Migration

php artisan make:migration add_indexes_to_activities_table
public function up()
{
    Schema::table('activities', function (Blueprint $table) {
        // Composite index for user activity queries
        $table->index(['user_id', 'type', 'created_at'], 'idx_user_activity');
    });
}

public function down()
{
    Schema::table('activities', function (Blueprint $table) {
        $table->dropIndex('idx_user_activity');
    });
}

Step 5: Run the Migration

php artisan migrate

On large tables, this might take a while. MySQL locks the table during index creation, so run it during low-traffic periods.

Step 6: Verify Performance

Check Debugbar again. Query time should drop to under 100ms.

Step 7: Monitor in Production

Use Laravel Telescope or a tool like New Relic to monitor query performance over time. As your table grows, you might need additional optimization.

This exact process helped me fix a client's analytics dashboard that was timing out. After adding three strategic indexes, every query completed in under 200ms.

Common Indexing Mistakes (and How to Fix Them)

I've made every indexing mistake in the book. Here are the ones that cost me the most time.

Mistake 1: Wrong Column Order in Composite Indexes

I created index(['status', 'user_id']) for a query that filtered by user_id first. MySQL couldn't use the index efficiently.

Fix: Put the most selective column first (usually foreign keys), then less selective ones. Order should match your most common query patterns.

Mistake 2: Over-Indexing Small Tables

I added indexes to a settings table with 15 rows because it felt "professional." Total waste, full table scans on 15 rows are microseconds.

Fix: Only index tables with 1,000+ rows or tables you know will grow significantly.

Mistake 3: Indexing Low-Cardinality Columns Alone

I indexed a gender column with only two values ('male', 'female'). The index was larger than the actual data and provided minimal benefit.

Fix: Low-cardinality columns work better as part of composite indexes, not standalone.

Mistake 4: Not Naming Indexes Explicitly

Laravel auto-generated index names that were 64+ characters and got truncated in MySQL. Debugging foreign key errors was a nightmare.

Fix: Always provide explicit names: $table->index('column', 'idx_short_name');

Mistake 5: Forgetting to Index Foreign Keys

I used unsignedBigInteger() for foreign keys without adding indexes. Queries with joins were painfully slow.

Fix: Always index foreign key columns, either with foreignId() or manually with index().

Mistake 6: Using VARCHAR for Indexes Too Long

I indexed a VARCHAR(500) description field. The index was huge and barely used because most queries didn't need it.

Fix: For text search, use full-text indexes. For general indexing, keep VARCHAR columns under 191 characters (Laravel's default for string columns).

Indexing Trade-offs: When to Skip Indexes

Not every column needs an index. Here's when I deliberately avoid them:

Write-Heavy Tables: If you're logging thousands of events per second, indexes slow down inserts significantly. I had a logs table where removing two indexes improved insert performance by 40%.

Temporary or Queue Tables: Tables that hold data briefly (like Laravel's jobs table) don't benefit much from indexes beyond the primary key.

Frequently Updated Columns: A column that changes on every update (like a last_modified_by field) requires constant index updates. Only index if you absolutely need to query it.

Boolean Flags with Even Distribution: A column like is_verified where 50% are true and 50% are false won't speed up queries much. MySQL often decides a full table scan is faster than using the index.

I use this mental model: Is this column used in WHERE, ORDER BY, or JOIN clauses in slow queries? If yes, index it. If no, skip it.

Advanced: Checking If MySQL Uses Your Index

You've added indexes, but is MySQL actually using them? Use the EXPLAIN statement:

DB::select(DB::raw("EXPLAIN SELECT * FROM users WHERE email = 'test@example.com'"));

Look at the key column in the results. If it shows your index name (like idx_users_email), you're good. If it says NULL, MySQL is ignoring your index.

Common reasons MySQL ignores indexes:

  • The query optimizer thinks a full scan is faster (on small tables)
  • You're using functions on indexed columns: WHERE LOWER(email) = ... breaks indexes
  • Data types don't match: comparing a string column to an integer
  • The index is on a column that allows NULL and your query includes NULL checks

Laravel Telescope has a "Queries" tab that shows if indexes were used. I check this for every slow query before optimizing.

Alternative Approaches to Consider

Indexes aren't the only solution for slow queries. Here are alternatives I've used:

Caching: For queries that don't need real-time data, cache results. In StudyLab, I cache leaderboard data for 5 minutes instead of indexing every column used in the ranking algorithm.

Pagination: Loading 10,000 rows at once is slow no matter how many indexes you have. Use Laravel's pagination or cursor-based pagination for large datasets.

Denormalization: Sometimes it's faster to store pre-calculated values. I have a users.total_orders column that's updated via database triggers instead of running COUNT() queries every time.

Database Partitioning: For tables with 10+ million rows, partition by date or user ID. I haven't needed this yet, but it's on my radar for when StudyLab scales further.

Read Replicas: Separate read queries from writes by using a read replica database. Laravel makes this easy with database configuration. This doesn't replace indexes but reduces load on your primary database.

Indexes should be your first optimization. These alternatives come into play when indexes alone aren't enough.

Conclusion: Index Strategically, Not Randomly

Database indexing in Laravel isn't complicated, but it requires thought. Add indexes to columns you filter, sort, or join on frequently. Skip indexes on write-heavy tables, low-cardinality columns, or tiny tables where full scans are already fast.

The three indexes I add to almost every project:

  1. Foreign key columns (user_id, post_id, etc.)
  2. Email/username columns for authentication
  3. Composite indexes on [user_id, status] for multi-tenant filtering

Start there, then profile your slow queries with Telescope or Debugbar. Let real performance data guide your indexing decisions, don't guess.

Next time you're tempted to add indexes everywhere "just in case," remember: every index costs write performance and disk space. Be strategic. Your database will thank you.

Need help optimizing your Laravel application's database performance? I've scaled applications from 1,000 to 1,000,000+ rows and can help you identify exactly which indexes will make the biggest impact. Let's work together


Need Help With Your Laravel Project?

I specialize in building custom Laravel applications, process automation, and SaaS development. Whether you need to eliminate repetitive tasks or build something from scratch, let's discuss your project.

⚡ Currently available for 2-3 new projects

Hafiz Riaz

About Hafiz Riaz

Full Stack Developer from Turin, Italy. I build web applications with Laravel and Vue.js, and automate business processes. Creator of ReplyGenius, StudyLab, and other SaaS products.

View Portfolio →

Get web development tips via email

Join 50+ developers • No spam • Unsubscribe anytime