Skip to content
ExponaBLOG
PHP & LARAVEL DEVELOPMENT · DAY 018 / 100

Laravel migrations: a PHP & Laravel guide for developers

Version-controlled database schema.

Expona Admin6 min read

Opening Hook

Database schema changes are part of every application's lifecycle, but managing them without version control quickly becomes a nightmare across teams and environments. Laravel migrations: a PHP & Laravel guide for developers shows you how to treat your database structure like code—with clear history, rollback capability, and confidence that your schema matches your application logic.

Why Migrations Matter in Production

You've probably experienced the pain: a developer changes the database manually, documentation goes out of sync, staging doesn't match production, and you spend hours debugging schema mismatches. Migrations solve this by making database changes reproducible, reviewable, and reversible.

In a team environment, migrations ensure everyone's local database evolves the same way. In production, they let you deploy schema changes safely with rollback capability. They're not optional tooling—they're how modern teams maintain database sanity.

Using php artisan make:migration

The migration command is your starting point. Creating a migration is straightforward:

php
php artisan make:migration create_users_table

Laravel generates a timestamped file in database/migrations/ with an up() and down() method. The timestamp prevents conflicts when multiple developers create migrations simultaneously.

For a new table, use the --create flag:

php
php artisan make:migration create_users_table --create=users

For modifying an existing table, use --table:

php
php artisan make:migration add_phone_to_users_table --table=users

The command scaffolds the boilerplate, but you fill in the actual schema logic. Here's what a generated file looks like:

php
<?php

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::create('users', function (Blueprint $table) {
            $table->id();
            $table->string('name');
            $table->string('email')->unique();
            $table->timestamp('email_verified_at')->nullable();
            $table->string('password');
            $table->rememberToken();
            $table->timestamps();
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('users');
    }
};

Notice the return statement with an anonymous class. This is modern Laravel syntax (8.0+). The up() method defines what happens when you run the migration forward; down() defines the rollback. Always implement both.

Using the Schema Builder, Not Raw SQL

New developers often reach for raw SQL inside migrations. Resist that urge. Laravel's schema builder is database-agnostic, testable, and readable. It abstracts away PostgreSQL vs MySQL differences, so your migrations work across environments.

Here's what not to do:

php
// DON'T do this
public function up(): void
{
    DB::statement('CREATE TABLE products (
        id INT PRIMARY KEY AUTO_INCREMENT,
        name VARCHAR(255),
        price DECIMAL(10, 2)
    )');
}

Instead, use the schema builder:

php
public function up(): void
{
    Schema::create('products', function (Blueprint $table) {
        $table->id();
        $table->string('name');
        $table->decimal('price', 10, 2);
        $table->timestamps();
    });
}

The schema builder gives you consistent column definitions across databases. It also makes migrations readable at a glance. Common column methods include:

  • $table->id() – Auto-incrementing primary key
  • $table->string('name', 100) – VARCHAR with optional length
  • $table->text('description') – Longer text field
  • $table->integer('count') – Integer column
  • $table->decimal('price', 8, 2) – Fixed precision numbers
  • $table->boolean('is_active') – Boolean flag
  • $table->timestamp('created_at') – Timestamp
  • $table->softDeletes() – Adds deleted_at for soft deletes
  • $table->foreignId('user_id')->constrained() – Foreign key

For modifying tables after creation, use the modify pattern:

php
public function up(): void
{
    Schema::table('users', function (Blueprint $table) {
        $table->string('email')->change();
        $table->nullable()->change();
    });
}

The schema builder keeps your migrations database-agnostic and maintainable.

Adding Indexes for Filter Columns

Indexes are easy to overlook in early migrations, then your queries slow to a crawl in production when you have millions of rows. Think ahead about how you'll query this data and index accordingly.

If you filter frequently on a column, index it. If you join on a foreign key, the database should have an index already (Laravel's constrained() handles this). If you search across multiple columns together, create a compound index.

Here's a migration with strategic indexes:

php
public function up(): void
{
    Schema::create('orders', function (Blueprint $table) {
        $table->id();
        $table->foreignId('user_id')->constrained();
        $table->enum('status', ['pending', 'shipped', 'delivered', 'canceled']);
        $table->dateTime('created_at');
        $table->dateTime('updated_at');
        
        // Single column indexes
        $table->index('user_id');
        $table->index('status');
        $table->index('created_at');
        
        // Compound index for queries filtering on both
        $table->index(['user_id', 'status']);
        
        // Unique index
        $table->unique('order_number');
    });
}

In this example, if you regularly query "where user_id = X and status = 'pending'", the compound index speeds up that lookup. Unique indexes enforce uniqueness and enable fast lookups on that column.

Don't over-index. Indexes speed reads but slow writes (insert, update, delete). Index what you query on—typically foreign keys, filter columns, and sort columns. Avoid indexing columns you almost never filter by.

You can add or drop indexes in separate migrations once the table exists:

php
public function up(): void
{
    Schema::table('orders', function (Blueprint $table) {
        $table->index('shipped_at');
    });
}

public function down(): void
{
    Schema::table('orders', function (Blueprint $table) {
        $table->dropIndex(['shipped_at']);
    });
}

Never Edit Migrations That Ran in Production

This is the cardinal rule. Once a migration has executed in production, treat it as immutable. Editing it breaks the contract between your local environment and production.

Here's the scenario: You create a migration, run it locally, commit it, but realize you forgot a column. You fix the migration file. Your teammate pulls, runs migrations, and gets the updated schema. But production already ran the old version—now you have a schema mismatch. Debugging this is painful and dangerous.

The solution: create a new migration. If you need to add that forgotten column, generate a new migration:

php
php artisan make:migration add_description_to_products_table --table=products

Then add your column:

php
public function up(): void
{
    Schema::table('products', function (Blueprint $table) {
        $table->text('description')->nullable();
    });
}

public function down(): void
{
    Schema::table('products', function (Blueprint $table) {
        $table->dropColumn('description');
    });
}

This keeps your migration history accurate and reversible. If you need to undo that column, you run php artisan migrate:rollback and the down() method executes.

If you catch the mistake before deploying to production, before committing, or in a local-only branch, editing the migration file is fine. Once it's in shared code or production, treat it as locked.

Common Mistakes

Forgetting the down() method: Always implement down(). It's not just nice-to-have—it's how you test rollbacks and fix mistakes. A down() that does nothing defeats the purpose of migrations.

Using raw SQL for everything: Raw SQL works but defeats the cross-database compatibility and clarity of the schema builder. Unless you're doing something genuinely database-specific, use the builder.

raulEditing production migrations: Covered above, but it bears repeating. A moment of convenience now means hours of debugging later.

Forgetting timestamps and soft deletes: Many tables should have created_at/updated_at. Many should support soft deletes. Include them in your migration design unless there's a specific reason not to. Laravel provides convenient helpers like $table->timestamps() and $table->softDeletes().

Missing indexes on foreign keys: Foreign keys should be indexed unless there's a very specific reason not to. Laravel's constrained() adds the index automatically, but when you manually define foreign keys, remember the index.

Not testing rollbacks: Before deploying, run php artisan migrate:rollback locally and verify down() executes cleanly. If down() fails, you'll have a bad time in production.

Key Takeaways

  • Create migrations with php artisan make:migration using descriptive names; use --create for new tables and --table for modifications.
  • Always use the schema builder instead of raw SQL for database-agnostic, readable, and maintainable migrations.
  • Add indexes to columns you filter, join, or sort on; avoid over-indexing to keep writes performant.
  • Never edit migrations after they've run in production; create new migrations for changes instead.
  • Always implement the down() method and test it locally before deploying.
  • Treat your migration history as a single source of truth for your database schema across all environments.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles