Home PHP Laravel Migrations Explained — Structure, Rollbacks & Real-World Patterns

Laravel Migrations Explained — Structure, Rollbacks & Real-World Patterns

In Plain English 🔥
Think of your database as a house you're renovating. Every time you knock down a wall, add a room, or install new wiring, you write it down in a renovation logbook — so any builder who joins the project later can reproduce every change in the exact right order. Laravel migrations are that logbook. Each migration file is one documented change to your database, and Laravel can replay or undo those changes on any machine, any time.
⚡ Quick Answer
Think of your database as a house you're renovating. Every time you knock down a wall, add a room, or install new wiring, you write it down in a renovation logbook — so any builder who joins the project later can reproduce every change in the exact right order. Laravel migrations are that logbook. Each migration file is one documented change to your database, and Laravel can replay or undo those changes on any machine, any time.

Your database schema is living, breathing code. It changes with every new feature, every pivot, every stakeholder request that comes in on a Friday afternoon. Without a disciplined system to track those changes, you end up with the classic nightmare: a production database that looks nothing like your local one, a colleague who manually added a column nobody else has, and a deploy that breaks spectacularly because a table column your code expects simply doesn't exist on the server. Laravel migrations exist to make that chaos impossible.

The problem they solve is deceptively simple: how do you keep database structure changes in sync across every environment — local, staging, production — and across every developer on a team? Raw SQL scripts dumped into a shared folder don't cut it. You need version control for your schema, with the ability to move forward and backward through changes, the same way Git lets you move through code commits. Migrations give you exactly that, built directly into the framework with a clean, expressive API.

By the end of this article you'll know how to design thoughtful migrations for real features, understand the difference between up() and down() and why down() is more important than most developers think, handle foreign keys and indexes correctly, avoid the three mistakes that bite intermediate developers, and feel confident talking about migrations in a technical interview.

How Migrations Work Under the Hood — The migrations Table Explained

When you run php artisan migrate for the very first time, Laravel does something before it touches any of your own migrations: it creates a table called migrations in your database. This table has two important columns — migration (the filename, minus the .php extension) and batch (an integer that groups migrations run together into one batch).

Every time you run migrate, Laravel reads the migrations table, compares it against the migration files in your database/migrations folder, and runs only the files it hasn't seen before. That's the entire tracking mechanism — dead simple, brutally effective.

The batch number is the key to rollbacks. When you run php artisan migrate:rollback, Laravel finds the highest batch number in the table and calls down() on every migration in that batch — in reverse order. So if you migrated three files in one migrate call, a single rollback undoes all three. Understanding this batch concept is what separates developers who use migrations correctly from those who accidentally roll back changes they didn't mean to.

This also means migrations are environment-agnostic. You run the exact same commands on local, CI, and production. The migrations table on each machine tracks its own state independently.

2024_01_15_000001_create_articles_table.php · PHP
123456789101112131415161718192021222324252627282930313233343536
<?php

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

// Each migration is a class that extends Migration.
// Laravel auto-discovers it — no registration needed.
return new class extends Migration
{
    /**
     * Run the migration — this is what `php artisan migrate` executes.
     * Build the table exactly as your application needs it right now.
     */
    public function up(): void
    {
        Schema::create('articles', function (Blueprint $table) {
            $table->id();                          // Unsigned BIGINT primary key, auto-increment
            $table->string('title', 200);          // VARCHAR(200) — cap it, don't leave it unbounded
            $table->string('slug', 200)->unique(); // Unique index created automatically by ->unique()
            $table->text('body');                  // TEXT column — no length limit
            $table->boolean('is_published')->default(false); // Sensible default — articles start as drafts
            $table->timestamp('published_at')->nullable();   // NULL until the article goes live
            $table->timestamps();                  // Adds created_at and updated_at (both nullable TIMESTAMP)
        });
    }

    /**
     * Reverse the migration — this is what `php artisan migrate:rollback` executes.
     * dropIfExists prevents an error if someone manually dropped the table already.
     */
    public function down(): void
    {
        Schema::dropIfExists('articles');
    }
};
▶ Output
$ php artisan migrate

INFO Running migrations.

2024_01_15_000001_create_articles_table ..................... 28ms DONE

$ php artisan migrate:rollback

INFO Rolling back migrations.

2024_01_15_000001_create_articles_table ..................... 12ms DONE
🔥
Why Anonymous Classes?Since Laravel 9, migrations use `return new class extends Migration` instead of a named class. This eliminates the risk of class name collisions when two developers create migrations on the same day — a real problem in large teams. Older projects still use named classes like `class CreateArticlesTable extends Migration` and that's perfectly fine, but for new projects, anonymous classes are the way to go.

Writing Migrations for Real Features — Foreign Keys, Indexes, and Column Modifiers

A create migration is just the beginning. Real applications constantly need to modify existing tables — adding columns, creating relationships, dropping obsolete fields. These are alter migrations, and they're where most intermediate developers make mistakes.

Foreign keys deserve special attention. Laravel's foreignId() method creates the column AND the index in one call. But you still need to call constrained() if you want the actual foreign key constraint at the database level. Without constrained(), you just have an integer column — no enforcement. The database will happily let you insert a user_id that points to a user who doesn't exist.

Indexes are equally important and equally overlooked. Every column you filter by in a WHERE clause or join on should have an index. Adding ->index() to a column in your migration is the right time to think about this — not after your queries start timing out in production with 500,000 rows.

Column modifiers like ->nullable(), ->default(), ->after(), and ->unsigned() are how you express business rules at the schema level. A price column should always be ->unsigned(). A deleted_at column for soft deletes should always be ->nullable(). These aren't just database hygiene — they're guards against bad data entering your system.

2024_01_15_000002_create_comments_table.php · PHP
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071
<?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
    {
        // --- Creating a table with a foreign key relationship ---
        Schema::create('comments', function (Blueprint $table) {
            $table->id();

            // foreignId() creates an UNSIGNED BIGINT column named 'article_id'
            // constrained() adds the actual FK constraint pointing to articles.id
            // cascadeOnDelete() means: if the article is deleted, delete its comments too
            $table->foreignId('article_id')
                  ->constrained()
                  ->cascadeOnDelete();

            // Same pattern for the user who wrote the comment
            $table->foreignId('user_id')
                  ->constrained()
                  ->cascadeOnDelete();

            $table->text('body');

            // Composite index: we almost always query comments BY article, ordered by date
            // A composite index on (article_id, created_at) makes that query fast
            $table->index(['article_id', 'created_at'], 'comments_article_date_index');

            $table->timestamps();
            $table->softDeletes(); // Adds deleted_at TIMESTAMP NULL — enables Eloquent soft deletes
        });
    }

    public function down(): void
    {
        // Foreign key constraints must be dropped BEFORE the table that holds them
        // dropIfExists handles both the FK constraints and the table in the correct order
        Schema::dropIfExists('comments');
    }
};

// ----------------------------------------------------------------
// SEPARATE FILE: Adding a column to an EXISTING table
// ----------------------------------------------------------------
// 2024_02_10_000001_add_excerpt_to_articles_table.php

return new class extends Migration
{
    public function up(): void
    {
        Schema::table('articles', function (Blueprint $table) {
            // ->after() places the column right after 'title' in the column order
            // This is MySQL-only — it's cosmetic but makes DESCRIBE output readable
            $table->string('excerpt', 500)
                  ->nullable()          // Existing rows have no excerpt — nullable prevents errors
                  ->after('title');     // Column ordering hint for readability
        });
    }

    public function down(): void
    {
        Schema::table('articles', function (Blueprint $table) {
            // Always drop the column in down() — make rollback a clean undo
            $table->dropColumn('excerpt');
        });
    }
};
▶ Output
$ php artisan migrate

INFO Running migrations.

2024_01_15_000002_create_comments_table ..................... 45ms DONE
2024_02_10_000001_add_excerpt_to_articles_table ............. 18ms DONE
⚠️
Watch Out: Foreign Key Order MattersIf your `comments` migration runs before the `articles` migration, the `constrained()` call will fail — the `articles` table doesn't exist yet. Laravel runs migrations in filename order, which is why the timestamp prefix matters. Always name your migration files so the table being referenced (articles) has an earlier timestamp than the table referencing it (comments). If you inherit a project where this is broken, `php artisan migrate:fresh` will reveal it immediately.

Rollbacks, Refresh, and the Seeder Workflow — Keeping Your Dev Loop Fast

Here's where migrations become genuinely powerful as a development tool, not just a deployment mechanism. The trio of migrate:rollback, migrate:refresh, and migrate:fresh are commands every intermediate developer should have muscle memory for — but each does something distinct.

migrate:rollback undoes the last batch only. It's surgical. Use it when you realise a migration you just ran has a mistake and you want to fix it before pushing.

migrate:refresh rolls back ALL migrations, then re-runs them all from scratch. It also accepts a --seed flag, which runs your database seeders after migrating. This is the command you run when you want a clean but fully-seeded local database — perfect at the start of a work session.

migrate:fresh is more aggressive. It drops every table (using DROP TABLE, not your down() methods) and re-runs all migrations. It's faster than refresh because it skips the rollback logic, but it means your down() methods are never tested. That's why you should run migrate:refresh occasionally — it proves your down() implementations actually work.

The --pretend flag on migrate is an underused gem: it prints the raw SQL that would be executed without actually running it. Essential for auditing migrations before they touch a production database.

DatabaseSeeder.php · PHP
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253
<?php

// database/seeders/DatabaseSeeder.php
// Seeders fill your freshly migrated database with realistic test data.
// They pair with migrations to give you a reproducible, usable dev environment.

namespace Database\Seeders;

use App\Models\Article;
use App\Models\Comment;
use App\Models\User;
use Illuminate\Database\Seeder;

class DatabaseSeeder extends Seeder
{
    public function run(): void
    {
        // Create 5 users using the User factory (defined in database/factories/)
        $users = User::factory(5)->create();

        // Create 20 articles, each assigned to a random one of those 5 users
        $articles = Article::factory(20)
            ->recycle($users)   // Reuse existing users rather than creating new ones
            ->create();

        // Create 3–8 comments per article, each from a random user
        $articles->each(function (Article $article) use ($users) {
            Comment::factory(
                rand(3, 8)   // Realistic variation — not every article has the same engagement
            )
            ->recycle($users)
            ->for($article)
            ->create();
        });

        // Report to the console so you can see the seeder is working
        $this->command->info('Database seeded with 5 users, 20 articles, and their comments.');
    }
}

/*
 * USEFUL COMMANDS:
 *
 * php artisan migrate:fresh --seed
 *   Drops all tables, re-runs all migrations, then runs DatabaseSeeder.
 *   Use this at the start of each dev session for a clean slate.
 *
 * php artisan migrate:refresh --seed
 *   Same result, but uses your down() methods — tests rollback logic too.
 *
 * php artisan migrate --pretend
 *   Prints the SQL that WOULD run. Great for reviewing before production deploys.
 */
▶ Output
$ php artisan migrate:fresh --seed

INFO Dropping all tables.
INFO Running migrations.

2024_01_15_000001_create_articles_table ..................... 28ms DONE
2024_01_15_000002_create_comments_table ..................... 45ms DONE
2024_02_10_000001_add_excerpt_to_articles_table ............. 18ms DONE

INFO Running seeders.

Database\Seeders\DatabaseSeeder ............................ 340ms DONE

Database seeded with 5 users, 20 articles, and their comments.
⚠️
Pro Tip: Never Edit a Pushed MigrationOnce a migration has been merged to a shared branch and run on any environment other than your own local machine, treat it as immutable. If you need to fix something, create a new migration that makes the correction. Editing an existing migration that teammates have already run means their `migrations` table won't match your file — and `migrate` will simply never run your fix because it thinks it already did.

Squashing Migrations and Multi-Environment Deployment — Production-Grade Patterns

After a year of active development, a project might have 80–120 migration files. Running them all on a fresh CI environment takes time, and the sheer number of files makes the database/migrations folder intimidating. Laravel's schema:dump command solves this.

php artisan schema:dump exports your current database schema as a raw SQL file stored in database/schema/. On future migrate runs, Laravel loads that SQL dump first (getting you to the current state instantly), then runs any migrations created after the dump. You get the speed of a single SQL file combined with the precision of individual migration files for recent changes.

For production deployments, the golden rule is: migrate goes in your deploy pipeline, not migrate:fresh. You'd never drop all tables on production. Most teams run php artisan migrate --force in their CI/CD pipeline — the --force flag bypasses the confirmation prompt that Laravel shows when it detects you're running in a production environment.

If your application has significant traffic, consider running migrations during a maintenance window and checking for zero-downtime migration patterns: adding columns as nullable first, backfilling data in a separate job, then adding constraints in a third migration. This approach prevents table locks from taking your app offline mid-deploy.

DeploymentMigrationPattern.php · PHP
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677
<?php

/**
 * ZERO-DOWNTIME MIGRATION PATTERN
 *
 * Scenario: You need to add a NOT NULL 'reading_time_minutes' column to a table
 * that already has 50,000 rows in production.
 *
 * Naive approach (DANGEROUS on live traffic):
 *   $table->integer('reading_time_minutes'); // Locks the table while backfilling NULL values!
 *
 * Safe 3-step approach below:
 */

// ── STEP 1: Add the column as NULLABLE (no lock, instant) ────────────────────
// 2024_03_01_000001_add_reading_time_to_articles_nullable.php
return new class extends Migration
{
    public function up(): void
    {
        Schema::table('articles', function (Blueprint $table) {
            // Nullable = existing rows get NULL immediately, no table scan needed
            $table->integer('reading_time_minutes')->nullable()->after('body');
        });
    }

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

// ── STEP 2: Backfill existing rows in a queued job (NOT in a migration) ───────
// app/Jobs/BackfillArticleReadingTime.php
//
// class BackfillArticleReadingTime implements ShouldQueue
// {
//     public function handle(): void
//     {
//         Article::whereNull('reading_time_minutes')
//             ->chunkById(500, function ($articles) {
//                 foreach ($articles as $article) {
//                     $wordCount = str_word_count(strip_tags($article->body));
//                     $article->update([
//                         'reading_time_minutes' => (int) ceil($wordCount / 200)
//                     ]);
//                 }
//             });
//     }
// }
//
// Dispatch this job AFTER Step 1 deploys. Let it run in the background.

// ── STEP 3: Add the NOT NULL constraint once all rows are backfilled ──────────
// 2024_03_05_000001_make_reading_time_not_nullable_on_articles.php
return new class extends Migration
{
    public function up(): void
    {
        Schema::table('articles', function (Blueprint $table) {
            // By now every row has a value. Safe to add constraint.
            $table->integer('reading_time_minutes')
                  ->default(1)       // Fallback for any edge cases missed by the backfill
                  ->nullable(false)  // Remove the nullable flag — adds NOT NULL constraint
                  ->change();        // ->change() tells Laravel to ALTER the existing column
        });
    }

    public function down(): void
    {
        Schema::table('articles', function (Blueprint $table) {
            $table->integer('reading_time_minutes')->nullable()->change();
        });
    }
};
▶ Output
-- Step 1 SQL (instant, no lock):
ALTER TABLE `articles` ADD `reading_time_minutes` INT NULL AFTER `body`;

-- Step 2: Background job runs asynchronously — no migration output.

-- Step 3 SQL (safe — all rows already have values):
ALTER TABLE `articles`
MODIFY `reading_time_minutes` INT NOT NULL DEFAULT 1;
⚠️
Watch Out: ->change() Requires doctrine/dbalIf you call `->change()` to modify an existing column and get a 'Class not found' error, you need to install the Doctrine DBAL package: `composer require doctrine/dbal`. This is a known dependency for column modification in Laravel 9 and below. In Laravel 10+, column modification was rewritten to use native SQL and no longer requires Doctrine — but always check your Laravel version first.
CommandWhat It DoesDestroys Data?Tests down()?Best Used When
migrateRuns pending migrations onlyNoNoNormal development and production deploys
migrate:rollbackUndoes the last batch onlyYes — last batchYesFixing a mistake in the migration you just ran
migrate:refreshRolls back all, then re-runs allYes — everythingYesYou need a clean DB but want to verify rollback logic
migrate:freshDrops all tables, then runs all migrationsYes — everythingNoFastest clean slate in local dev — CI pipeline resets
migrate --pretendPrints SQL without executing itNoNoAuditing migrations before a production deploy
schema:dumpSquashes all migrations into one SQL fileNoNoLarge projects with 50+ migrations slowing down CI

🎯 Key Takeaways

  • The migrations table tracks which files have run and groups them by batch — understanding batch numbers is the key to predicting what migrate:rollback will undo.
  • down() isn't optional boilerplate — it's your safety net. A down() that doesn't cleanly reverse up() will fail loudly in the worst possible moment: a midnight rollback on production.
  • foreignId('user_id')->constrained() does two things: creates the column AND the foreign key constraint. Without constrained(), you just have an unguarded integer column — referential integrity is not enforced.
  • Never run migrate:fresh on any shared environment. It has no confirmation for existing data and no way to undo it. migrate:fresh is a local-development-only command.

⚠ Common Mistakes to Avoid

  • Mistake 1: Editing a migration that's already been run by teammates — The migrate command won't re-run it because the filename already exists in the migrations table. Your changes are silently ignored. Fix: Always create a new migration file for any correction. Use php artisan make:migration alter_articles_add_missing_index and make the change there.
  • Mistake 2: Writing a down() method that doesn't actually reverse up() — For example, up() adds a column with ->index() but down() only calls dropColumn() without dropIndex() first. On some databases this causes a foreign key or index constraint error during rollback. Fix: Think of down() as a mirror image of up() executed in reverse. Drop indexes before dropping columns, and drop foreign keys before dropping the columns that hold them.
  • Mistake 3: Using migrate:fresh on a staging environment shared with the client or QA team — It drops every table instantly, with no warning, destroying all test data that's been entered. The --force flag makes it worse. Fix: Reserve migrate:fresh for your local machine only. On shared environments, always use migrate for new changes. If a reset is truly needed, get explicit sign-off and make a backup first with php artisan db:wipe preceded by a database dump.

Interview Questions on This Topic

  • QWhat's the difference between `migrate:refresh` and `migrate:fresh`, and when would you choose one over the other in a team environment?
  • QIf a colleague tells you they edited an existing migration file to fix a bug, what problems could that cause and how would you have handled it differently?
  • QHow would you safely add a NOT NULL column to a table that already has millions of rows in production without causing downtime?

Frequently Asked Questions

What happens if I delete a migration file that has already been run?

Laravel will throw an error if you try to roll back and the file no longer exists, because down() can't be called on a missing file. The entry in the migrations table still exists, but there's no corresponding class to execute. Never delete migration files that have been run in any environment — if you need to undo a change, write a new migration.

Can I run a single specific migration instead of all pending ones?

Not directly via an artisan flag. The standard migrate command runs all pending files in order. If you need to run one specific migration, the common workaround is to use migrate --path=database/migrations/your_file.php. Just be aware this can create batching inconsistencies, so use it sparingly and only in local development.

What's the difference between `$table->string()` and `$table->text()` in migrations?

Both store character data, but string() maps to VARCHAR with a default max of 255 characters and can be indexed (great for searchable fields like slugs or emails). text() maps to a TEXT column with no practical length limit, but TEXT columns cannot be fully indexed in MySQL — only a prefix of the column can be indexed. Use string() for short, searchable values and text() for long-form content like article bodies.

🔥
TheCodeForge Editorial Team Verified Author

Written and reviewed by senior developers with real-world experience across enterprise, startup and open-source projects. Every article on TheCodeForge is written to be clear, accurate and genuinely useful — not just SEO filler.

← PreviousLaravel Blade TemplatesNext →Laravel Middleware
Forged with 🔥 at TheCodeForge.io — Where Developers Are Forged