M
MeshWorld.
Laravel Database Migrations PHP 7 min read

Create Composite Indexes in Laravel Migrations

Vishnu
By Vishnu
| Updated: Mar 27, 2026

A composite index in Laravel is a database index that spans two or more columns. You create it in a migration using $table->index(['column1', 'column2']). It speeds up queries that filter on those columns together — significantly more than two separate single-column indexes would for the same query. If your app runs WHERE email = ? AND status = ? frequently, a composite index on (email, status) is the right fix.

This guide covers the syntax, the auto-naming convention, column order rules, and how to drop a composite index cleanly — updated for Laravel 12.

:::note[TL;DR]

  • Use $table->index(['col1', 'col2']) to create a composite index in a migration
  • Laravel auto-names it {table}_{col1}_{col2}_index
  • Pass a second argument to override the name: $table->index(['col1', 'col2'], 'my_custom_index')
  • Column order matters: the index only helps queries that start with the leftmost column
  • Drop by auto-generated name or by passing the column array to dropIndex() :::

What is a composite index and when should you use one?

A composite index — also called a compound index — is a single index entry that covers multiple columns. The database engine uses it when a query’s WHERE clause matches the index’s leftmost column prefix.

The practical rule: if you’re filtering on multiple columns together in a hot query path, a composite index is almost always faster than separate indexes on each column.

The scenario: You’re building a notifications table. Every page load fires WHERE user_id = ? AND read_at IS NULL to fetch unread counts. The table has 2 million rows. A single index on user_id helps, but the DB still scans all of that user’s rows to check read_at. A composite index on (user_id, read_at) lets the engine resolve both conditions in one index scan. Your query goes from 40ms to 4ms. Your users stop filing slow-load tickets.

You can also read about single-column index creation with migrations if you need the basics first.

What is the syntax for a composite index in Laravel?

The index() method on Blueprint accepts an array of column names. Laravel handles the rest — the SQL CREATE INDEX statement, the naming, and the rollback.

The basic syntax is straightforward. Pass the column names as an array:

// Basic composite index — Laravel auto-generates the name
$table->index(['column1', 'column2']);

// With a custom index name
$table->index(['email', 'phone_number'], 'users_contact_index');

The second argument is optional. Use it when the auto-generated name would exceed your database’s index name length limit (MySQL has a 64-character limit), or when you want a predictable name to reference in dropIndex().

What does a full anonymous migration look like?

Laravel 11 and 12 default to anonymous migrations — return new class extends Migration instead of a named class. Both syntaxes work, but anonymous migrations avoid class name collisions when you have multiple migration files touching the same table.

Here’s a complete migration that adds a composite index on an existing users table, including a proper down() method:

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    public function up(): void
    {
        Schema::table('users', function (Blueprint $table) {
            $table->index(['email', 'phone_number']);
        });
    }

    public function down(): void
    {
        Schema::table('users', function (Blueprint $table) {
            // Pass the column array — Laravel resolves the auto-generated name
            $table->dropIndex(['email', 'phone_number']); // drops users_email_phone_number_index
        });
    }
};

Run it with:

php artisan migrate

The down() method is important. An empty down() means php artisan migrate:rollback silently does nothing — which causes confusion in staging environments when you’re iterating quickly.

:::warning Old-style named class migrations (class AlterUsersTable extends Migration) still work in Laravel 12 — they haven’t been removed. But anonymous migrations (return new class) are the default generated by php artisan make:migration since Laravel 11. If two migration files in the same project define a class with the same name, PHP throws a fatal error at runtime. Anonymous migrations don’t have this problem. Prefer them for new migrations. :::

How does Laravel auto-name composite indexes?

Laravel generates the index name by joining the table name and column names with underscores, then appending _index.

For a users table with columns email and phone_number, the auto-generated name is:

users_email_phone_number_index

That name is what gets written to the migrations table and to your database schema. If you’re dropping an index by name rather than by column array, that’s the string you need:

// Drop by auto-generated name
$table->dropIndex('users_email_phone_number_index');

// Drop by column array — Laravel resolves the name for you
$table->dropIndex(['email', 'phone_number']);

// Drop a custom-named index
$table->dropIndex('users_contact_index');

Either dropIndex approach works. The column-array form is safer if you’re not sure what the auto-generated name is.

Does column order in a composite index matter?

Yes. This is the part that catches people out.

A composite index on (email, status) is usable by queries that start with email. It is not usable by queries that only filter on status alone.

Here’s the mental model. Think of the index as a phone book sorted first by last name, then by first name. You can look up everyone named “Smith” quickly. You can look up “John Smith” even faster. But you cannot look up everyone named “John” without scanning the whole book — the first-name column isn’t at the front.

Composite index on (email, status):
  ✓  WHERE email = ?
  ✓  WHERE email = ? AND status = ?
  ✗  WHERE status = ?   — full table scan, index not used

Put your most selective column (the one that filters to the fewest rows) first. For multi-tenant apps, user_id or tenant_id almost always goes first.

The scenario: You build a WHERE status = 'active' query and add a composite index on (status, user_id) thinking it’ll help user lookups too. It does — but only partially. Later a teammate adds WHERE user_id = ? AND status = 'active' queries everywhere. They check EXPLAIN and find the index isn’t being used. The fix is just reordering the index to (user_id, status). One migration to swap them. An hour of confusion avoided if you’d known this upfront.

For related indexing patterns, see the guide on single-column indexes in Laravel migrations.

Summary

  • $table->index(['col1', 'col2']) creates a composite index spanning both columns
  • Laravel auto-names it {table}_{col1}_{col2}_index; pass a second argument to override
  • Anonymous migration syntax (return new class) is the Laravel 11/12 default — use it for new migrations
  • Column order determines which queries benefit: the index works left-to-right, starting from the first column
  • Drop with $table->dropIndex(['col1', 'col2']) or by passing the index name string

FAQ

Can I add a composite index when creating a table, not just altering one? Yes. $table->index() works inside Schema::create() the same way it works inside Schema::table(). Just add the index() call at the end of your column definitions.

What’s the difference between a composite index and a unique composite index? $table->index() creates a plain (non-unique) index — it speeds up reads but allows duplicate value combinations. $table->unique(['col1', 'col2']) creates a unique constraint on the combination, preventing duplicate pairs. Use unique() when the column combination should be a natural key (e.g., user_id + provider for OAuth accounts).

How many columns can a composite index have? MySQL supports up to 16 columns per index. In practice, indexes beyond 3–4 columns are rarely useful and add write overhead. If you’re indexing more than 3 columns, reconsider the query — it might be better served by a covering index or a schema change.

Does adding a composite index slow down inserts and updates? Yes, but the tradeoff is usually worth it for read-heavy tables. Every write to an indexed column updates the index. On tables with millions of rows and high write frequency (e.g., event logs), benchmark before adding indexes in production.

What happens if I run migrate:rollback with an empty down() method? Nothing. The rollback command runs down() — if it’s empty or missing dropIndex(), the index stays in the database. Your migration state in the migrations table gets rolled back, but the actual database schema doesn’t match. Always implement down().