M
MeshWorld.
Laravel Database Migrations PHP 6 min read

Create Database Indexes in Laravel Migrations

Vishnu
By Vishnu
| Updated: Mar 27, 2026

Indexes speed up WHERE, ORDER BY, and JOIN queries by letting the database skip full table scans. Laravel’s Schema Builder exposes every major index type as a method. This guide covers adding, naming, and dropping them using Laravel 12 anonymous migration syntax — no class names, just return new class.

:::note[TL;DR]

  • $table->index('col') adds a regular index for read-heavy columns
  • $table->unique('col') enforces uniqueness and adds an index at the same time
  • $table->fullText('body') enables MATCH...AGAINST queries — MySQL and PostgreSQL only
  • Laravel auto-generates index names in the format table_column_type; you can override them
  • Drop indexes by auto-generated name or pass a column array and let Laravel build the name :::

When should you add an index in a migration?

Every time you run a filtered query on a column that isn’t the primary key, you’re a candidate for an index. If a table grows past a few thousand rows and you’re querying by author_id, slug, or status, an index cuts query time from seconds to milliseconds.

Add indexes in a separate migration from the column definitions. That way, a failed index creation doesn’t roll back your column changes too.

How do you add a single-column index?

Pass the column name to $table->index(). That’s the whole thing. Laravel generates a name automatically in the format table_column_index.

// Speeds up: WHERE email = ?
$table->index('email');

// With a custom name (overrides the auto-generated default):
$table->index('email', 'users_email_index');

Custom names matter when the auto-generated name exceeds your database’s identifier length limit (64 chars on MySQL). Long table and column names can hit this silently.

The Scenario: Your posts table has 500k rows and the “recent by author” query is slow. You add $table->index('author_id') in a new migration, run php artisan migrate, and the query drops from 800ms to 4ms. That’s the whole point.

How do you create a unique index?

$table->unique() enforces uniqueness at the database level and adds an index in one call. It’s both a constraint and a performance boost.

// Single column — enforces uniqueness AND speeds up lookups
$table->unique('email');

// Composite unique — the combination must be unique, not each value independently
$table->unique(['email', 'company_id']);

The composite form is useful for things like “one user per team” or “one slug per category.” Neither column is unique on its own, but the pair must be.

For composite indexes that aren’t unique, see create composite indexes in Laravel migrations.

How do you set a primary key in a migration?

Most of the time Laravel handles this automatically with $table->id(). But when you need a custom primary key or a composite primary key, you call it explicitly.

// Single column primary key
$table->primary('id');

// Composite primary key — useful for pivot tables
$table->primary(['user_id', 'role_id']);

Composite primary keys are common in pivot tables where you never want the same pair of IDs twice.

How do you add a full-text index?

Full-text indexes enable MATCH...AGAINST queries on MySQL and equivalent full-text search on PostgreSQL. They’re built for searching large text columns — blog post bodies, product descriptions, comments.

// Enables MATCH...AGAINST queries on MySQL, or GIN/tsvector on Postgres
$table->fullText('body');

:::warning fullText() is only supported on MySQL and PostgreSQL. SQLite and SQL Server don’t support it — calling it on those drivers throws an error at migration time. If you’re running SQLite in local development and MySQL in production, you’ll hit this mismatch during php artisan migrate. :::

What does a complete anonymous migration look like?

Laravel 11 and 12 default to anonymous migration classes — no class name, just return new class. Here’s a full example adding multiple index types to a posts table:

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('posts', function (Blueprint $table) {
            $table->index('author_id');
            $table->unique('slug');
            $table->fullText('body');
        });
    }

    public function down(): void
    {
        Schema::table('posts', function (Blueprint $table) {
            $table->dropIndex('posts_author_id_index');
            $table->dropUnique('posts_slug_unique');
            $table->dropFullText('posts_body_fulltext');
        });
    }
};

The down() method is important. If a deploy goes wrong and you need to roll back, missing drop calls leave orphaned indexes and cause the next migrate:rollback to fail.

How do you drop an index?

Two ways. By auto-generated name, or by passing the column array and letting Laravel build the name.

// Drop by auto-generated name — format is table_col_type:
$table->dropIndex('posts_author_id_index');
$table->dropUnique('posts_slug_unique');
$table->dropPrimary('users_id_primary');

// Drop by column array — Laravel generates the name for you:
$table->dropIndex(['author_id']);

The column array form is safer when you’re not sure what the auto-generated name is. The explicit name form is clearer in code review.

The Scenario: You’re reviewing a pull request and see $table->dropIndex(['author_id']). You have to mentally reconstruct what name that generates. If the PR used $table->dropIndex('posts_author_id_index') instead, the intent is obvious at a glance. Both work — one is easier to audit.

Summary

  • index(), unique(), primary(), and fullText() are the four main index types in Laravel migrations
  • Auto-generated index names follow table_column_type — override when names get too long
  • Always write down() with matching drop*() calls so rollbacks work cleanly
  • fullText() only works on MySQL and PostgreSQL — guard against driver mismatches in development

FAQ

Does adding an index slow down write operations? Yes, slightly. Every INSERT and UPDATE on an indexed column also updates the index structure. For read-heavy tables it’s almost always worth it. For write-heavy tables, benchmark before indexing every column.

Can I add an index to an existing column without downtime? On MySQL, CREATE INDEX takes a brief table lock on smaller tables. For large tables in production, use CREATE INDEX CONCURRENTLY on PostgreSQL, or schedule the migration during low-traffic windows on MySQL.

What’s the difference between index() and unique()? Both create a B-tree index. unique() also adds a uniqueness constraint — a duplicate insert will fail with a database error. index() has no constraint, just the performance benefit.

Why does Laravel generate index names instead of letting the database name them? So you can reference them by name in dropIndex(). Database-generated names vary between engines. Consistent naming makes migrations portable.

Should I index foreign key columns? Yes. Laravel doesn’t add an index automatically when you call $table->foreignId(). You need to add it explicitly, or use ->constrained() which does add the index as part of the foreign key constraint.