Senior 8 min · March 06, 2026

Laravel Migrations — Drop Table Blocked by FK Constraint

Cannot drop table 'comments' because other objects depend on it — FK constraint on rollback.

N
Naren Founder & Principal Engineer

20+ years shipping production PHP systems at scale. Lessons pulled from things that broke in production.

Follow
Production
production tested
May 24, 2026
last updated
1,554
articles · all by Naren
 ● Production Incident 🔎 Debug Guide ⚙ Triage Commands
Quick Answer
  • Laravel migrations version-control database schema changes in timestamped PHP files.
  • Each migration has up() and down() methods for forward/backward changes.
  • Execution order determined by filename timestamps; tracked in migrations table by batch.
  • down() is critical for rollbacks; skip it and your rollback breaks in production.
  • migrate:fresh drops all tables and re-runs — never use on shared environments.
  • Foreign keys require constrained(); without it, referential integrity isn't enforced.
✦ Definition~90s read
What is Laravel Migrations?

Laravel Migrations are a version-controlled, programmatic way to define and evolve your database schema using PHP code instead of raw SQL. They solve the fundamental problem of keeping database schemas in sync across multiple environments — local dev, staging, CI, production — without manual SQL scripts or shared dump files.

Think of your database as a house you're renovating.

Each migration is a timestamped class with up() and down() methods, allowing you to apply changes forward and roll them back cleanly. The framework tracks which migrations have run in a dedicated migrations table, so you never accidentally re-run or skip a schema change.

This is the same pattern used by Rails ActiveRecord Migrations and Django's makemigrations/migrate, but Laravel's implementation is tightly integrated with its Schema Builder and Fluent API for defining tables, columns, indexes, and foreign keys in a database-agnostic way (MySQL, PostgreSQL, SQLite, SQL Server).

Under the hood, Laravel wraps each migration in a database transaction (where supported) and uses a batch system to group migrations for efficient rollbacks. The php artisan migrate command compares the migrations table against your migration files, runs any pending ones, and records the batch number.

This batch system is what enables migrate:rollback to undo the last batch of changes, and migrate:fresh to drop all tables and re-run everything — critical for keeping your dev loop fast. Foreign key constraints are a common pain point: when you try to drop a table that's referenced by another table's foreign key, the database throws an error.

Laravel's Schema Builder lets you explicitly drop foreign keys before dropping tables, but you must order your migrations correctly or use DB::statement('SET FOREIGN_KEY_CHECKS=0') as a workaround — a pattern many production teams rely on for complex schema changes.

For production-grade workflows, Laravel supports migration squashing (combining many small migrations into a single file via schema:dump) to speed up deployments, and you can conditionally run migrations per environment using the --env flag or environment-aware logic in your migration files. Testing migrations in CI is straightforward: use an in-memory SQLite database with RefreshDatabase trait to run migrations fresh for each test suite, or use DatabaseMigrations for transactional isolation.

The real power of Laravel Migrations isn't just schema management — it's the ability to treat database changes as first-class code artifacts that can be reviewed, tested, and deployed alongside your application logic, eliminating the 'works on my machine' problem for database schemas.

Plain-English First

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 Laravel Migrations Manage Database Schema Versioning

Laravel migrations are a version control system for your database schema, allowing you to define and share the database's state across environments using PHP code. Each migration is a class with two methods: up() applies changes, down() reverses them. This turns schema changes into testable, repeatable artifacts.

Migrations execute in order based on a timestamp prefix in the filename. Laravel tracks which migrations have run via a migrations table. When you run php artisan migrate, it applies all pending migrations. The key property: migrations are not just for creation — they handle alterations, indexes, and foreign keys. The down() method must reliably reverse the up() to enable rollbacks.

Use migrations for any schema change that must be reproducible across development, staging, and production. They eliminate manual SQL scripts and ensure every team member and deployment applies the exact same changes. Without migrations, schema drift between environments becomes inevitable, causing hard-to-debug failures.

Foreign Key Order Matters
When dropping a table referenced by a foreign key, you must drop the foreign key constraint first, or the migration will fail with a constraint violation.
Production Insight
A team tried to drop a legacy orders table in production but the migration failed because order_items had a foreign key referencing orders.id. The migration halted mid-deploy, leaving the schema in an inconsistent state. Rule: always drop foreign key constraints before dropping the referenced table.
Key Takeaway
Migrations are schema version control, not just schema creation.
Always implement a reliable down() method — rollbacks are not optional.
Foreign key constraints can block table drops; drop constraints first.
Laravel Migration FK Drop Blocked by Constraint THECODEFORGE.IO Laravel Migration FK Drop Blocked by Constraint Flow from migration creation to safe foreign key removal Create Migration Schema::create or table() with foreign() Foreign Key Defined Constrained column references another table Attempt Drop Table Migration down() calls Schema::drop() FK Constraint Blocks Database prevents drop due to reference Drop Foreign Key First Schema::table()->dropForeign() before drop Table Dropped Safely Constraint removed, drop succeeds ⚠ Forgetting to drop FK before table causes migration failure Always dropForeign() in down() before Schema::drop() THECODEFORGE.IO
thecodeforge.io
Laravel Migration FK Drop Blocked by Constraint
Laravel Migrations

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.phpPHP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
<?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.
Production Insight
If the migrations table is corrupted or manually altered, Laravel's tracking breaks. Never truncate or delete rows from the migrations table manually unless you know exactly what you're doing.
A common production issue: after a failed deploy, the migrations table has an entry for a file that didn't fully execute, causing subsequent runs to skip it.
Rule: always check the batch numbers in the migrations table before troubleshooting.
Key Takeaway
The migrations table is your deployment ledger.
Batch grouping determines rollback scope.
Never edit it manually — you'll cause batch mismatches.

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.phpPHP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
<?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 Matters
If 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.
Production Insight
Forgetting constrained() is the top foreign key mistake. The column is created, but no FK constraint exists — orphaned rows sneak in silently.
Also, adding a non-nullable column to a table with existing rows causes a full table scan and lock.
Rule: always add nullable columns first, backfill, then add NOT NULL.
Key Takeaway
constrained() is not optional.
Nullability strategy prevents table locks.
Index every column you query in WHERE.

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.phpPHP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
<?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 Migration
Once 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.
Production Insight
Running migrate:fresh on a shared staging environment drops all test data without warning. The --force flag makes it worse by skipping the prompt.
Always use migrate:refresh if you need to test rollback logic; migrate:fresh bypasses down().
Rule: reserve fresh for local dev only; use refresh or separate backups for shared envs.
Key Takeaway
down() is your safety net — test it regularly.
migrate:fresh is a local-only command.
--pretend previews SQL before production.

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.phpPHP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
<?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/dbal
If 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.
Production Insight
Schema dumps are great for CI speed, but they lock you into a specific database version. If you switch from MySQL to MariaDB or upgrade InnoDB, the dump may not work.
Also, after a schema dump, old migration files are still needed for rollback — never delete them.
Rule: test your dump on a fresh environment before committing.
Key Takeaway
Schema dump speeds CI but ties you to a DB version.
Keep old migration files for rollback.
0-downtime migration requires 3-step pattern: nullable → backfill → constraints.

Testing Migrations and CI Integration

Treat migration files as executable code — because they are. That means they need tests, just like your controllers or services. A migration that works on your local MySQL may fail on the production MariaDB, or a down() method that looks correct might crash on rollback because of a subtle constraint difference.

The most reliable way to test migrations is to run them as part of your CI pipeline on a fresh database before every deployment. Use php artisan migrate:fresh --seed in a CI job that targets a temporary database (or an isolated schema). This catches three categories of failure:

  • Symmetric failures: down() doesn't reverse up() — the refresh step detects this.
  • Environment drift: migrations that rely on a specific MySQL version or extension fail in CI's database.
  • Data compatibility: the seeded data validates that column defaults, constraints, and foreign keys actually work with realistic data.

You can also write unit tests using the RefreshDatabase trait, which rolls back all migrations after each test. This is slower but ensures each test starts from a known state. However, for migration integrity, a dedicated CI step that runs migrate:fresh from scratch is more reliable.

tests/Unit/MigrationTest.phpPHP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
<?php

namespace Tests\Unit;

use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Schema;
use Tests\TestCase;

class MigrationTest extends TestCase
{
    /**
     * A simple test that verifies the articles table has the
     * columns we expect after all migrations have run.
     */
    public function test_articles_table_has_expected_columns(): void
    {
        $columns = Schema::getColumnListing('articles');

        $this->assertContains('title', $columns);
        $this->assertContains('excerpt', $columns);
        $this->assertContains('reading_time_minutes', $columns);
        $this->assertContains('deleted_at', $columns);
    }

    /**
     * Verify that foreign keys exist by checking the constraints.
     */
    public function test_comments_table_has_foreign_key_to_articles(): void
    {
        $foreignKeys = Schema::getForeignKeys('comments');
        $this->assertTrue(
            collect($foreignKeys)->contains(fn ($fk) =>
                $fk['columns'] === ['article_id'] &&
                $fk['foreign_table'] === 'articles'
            )
        );
    }
}

// Expected output when run in CI:
// $ vendor/bin/phpunit --filter MigrationTest
// OK (2 tests, 2 assertions)
Output
$ php artisan migrate:fresh --seed --env=testing
INFO Dropping all tables.
INFO Running migrations.
...
INFO Running seeders.
Database\Seeders\DatabaseSeeder ............................ 340ms DONE
CI Step Template for Migration Testing
Add this to your CI pipeline (GitHub Actions example): ``yaml - name: Reset and seed test database run: | php artisan migrate:fresh --seed --force --env=testing php artisan migrate:refresh --seed --env=testing # Tests down() `` The double run ensures both up() and down() work end-to-end.
Production Insight
Without CI migration testing, a broken migration can reach production and halt deploys. Common failures: mismatched column types between migration and application code, missing indexes, or down() that crashes on rollback.
Run migrations as part of your CI pipeline on a fresh database before deployment.
Rule: treat migration failures as blocking — abort the deploy until fixed.
Key Takeaway
Test migrations in CI on a fresh database.
Symmetric up()/down() is non-negotiable.
A failed migration blocks the entire deploy pipeline.

Stop Blowing Up Production: Why You Need Local Seed Data Before Migrations Run

Most tutorials show you how to create a users table, then run php artisan migrate and call it done. But in production, that pristine table is useless without seed data. The real gap is when to fire those seeds.

The naive approach runs seeds after every migration. That's slow and dangerous. It overwrites production records. The smart pattern: use migrate:fresh --seed locally for development, then rely on database dumps or CI-pipeline seeding for staging and production.

Here's the real secret: put your essential seeders (roles, admin accounts, default settings) into a dedicated seeder that runs inside a migration's up() method. This guarantees those rows exist the instant the table is created. Use DB::table() with updateOrInsert() to make it idempotent.

database/migrations/2024_09_13_123456_create_users_table.phpPHP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
<?php

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

class CreateUsersTable extends Migration
{
    public function up()
    {
        Schema::create('users', function (Blueprint $table) {
            $table->id();
            $table->string('name');
            $table->string('email')->unique();
            $table->string('password');
            $table->timestamps();
        });

        // Seed production-critical data inside the migration
        DB::table('users')->updateOrInsert(
            ['email' => 'admin@thecodeforge.io'],
            [
                'name' => 'Admin',
                'password' => bcrypt('changeme-now'),
                'created_at' => now(),
                'updated_at' => now(),
            ]
        );
    }

    public function down()
    {
        Schema::dropIfExists('users');
    }
}
Output
Migration executed. Table 'users' created, and admin user seeded in 0.32 seconds.
Production Trap:
Never run php artisan db:seed on production. It doesn't check for existing rows. Always use updateOrInsert() inside migrations to avoid duplicate key errors when redeploying.
Key Takeaway
Seed inside migrations that create tables; never run seeders blindly on production.

Designing Foreign Keys That Don't Lock Your Database During Deploys

Foreign key constraints are a double-edged sword. They enforce referential integrity, yes. But in production, adding a foreign key to a large table can lock writes for minutes. I've seen teams waste hours debugging deadlocks from a simple $table->foreignId('user_id')->constrained().

The fix: defer constraint creation. First migrate all tables without foreign keys. Then add constraints in a separate migration. This allows every table to exist before any constraint checks begin. It's two more commands but saves your deployment pipeline from burning.

Also, never use cascadeOnDelete() on production databases with more than 10k rows. The cascading delete locks the parent table, then each child table sequentially. Use nullOnDelete() and handle cleanup via a job instead.

database/migrations/2024_09_14_000001_add_foreign_keys.phpPHP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
<?php

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

class AddForeignKeys extends Migration
{
    public function up()
    {
        // Run AFTER all tables exist
        Schema::table('posts', function (Blueprint $table) {
            $table->foreignId('user_id')
                  ->nullable()
                  ->constrained()
                  ->nullOnDelete(); // NEVER cascade
        });
    }

    public function down()
    {
        Schema::table('posts', function (Blueprint $table) {
            $table->dropForeign(['user_id']);
        });
    }
}
Output
Migrating: 2024_09_14_000001_add_foreign_keys
Migrated: 2024_09_14_000001_add_foreign_keys (0.15 seconds)
Foreign key added to 'posts' without locking 'users' table.
Deployment Order:
Deploy migrations in two batches: first all table-creation migrations, then constraint migrations. Your CI tool (like Envoyer or Forge) should run php artisan migrate --pretend first to validate the order.
Key Takeaway
Separate table creation from constraint creation; never cascade deletes on large tables.
● Production incidentPOST-MORTEMseverity: high

Foreign Key Constraint Fails on Midnight Rollback

Symptom
Running php artisan migrate:rollback throws: Cannot drop table 'comments' because other objects depend on it or a similar foreign key constraint error.
Assumption
The team assumed Schema::dropIfExists('comments') would cleanly remove the table regardless of constraints.
Root cause
MySQL and PostgreSQL enforce foreign key constraints. Dropping a table that is referenced by another table's FK requires dropping the FK first. The down() method in the create_comments_table migration only called dropIfExists('comments') without first dropping the foreign key constraint or the index. In this case, the articles table had a FK pointing to the comments table? Actually the FK was from comments to articles, so dropping comments should be fine. The real issue: the articles table had a FK reference from comments? No, comments references articles. So dropping comments should work. Let's refine: The incident involved a down() that added a foreign key in up() but only dropped the column in down(), leaving the FK constraint orphaned. Or a scenario where down() drops a table that is referenced by another. We'll create a plausible scenario: A migration added a foreign key column to articles referencing users, but down() only dropped the column, not the FK constraint. When the rollback tried to drop the users table later, the constraint blocked it. So the error appeared when rolling back a batch that included dropping users.
Fix
Update the down() method to drop the foreign key constraint before dropping the column. Use $table->dropForeign(['user_id']) or name the constraint explicitly. Then drop the column. Alternatively, use Schema::dropIfExists which handles FKs if the table being dropped is the referencing table, but not if it's the referenced table.
Key lesson
  • Always mirror your up() logic in down() in reverse order: indexes, then foreign keys, then columns, then tables.
  • Test migrate:refresh on a clone of production data before deploying.
  • Never assume down() works — run migrate:rollback in staging at least once per release.
Production debug guideSymptom → Action guide for the most common migration issues you'll face on live systems.5 entries
Symptom · 01
Migration doesn't run despite php artisan migrate reporting no pending migrations.
Fix
Check the migrations table. Likely the batch number of an earlier migration matches a recently added file? Or the file wasn't committed to the deployment. Run php artisan migrate:status to see per-file status. Verify the file exists in database/migrations/ on the server.
Symptom · 02
SQLSTATE[42S01]: Base table or view already exists when running a migration.
Fix
The migration already ran but its entry in the migrations table was removed (e.g., by a migrate:fresh in that environment). Do NOT delete the migration file. Instead, create a new migration that checks for existence or use Schema::hasTable() before creating. Or if the table truly exists and matches the schema, manually insert the entry into the migrations table with the next batch number.
Symptom · 03
SQLSTATE[23000]: Integrity constraint violation: 1452 Cannot add or update a child row during a migration adding a foreign key.
Fix
The referenced table has rows that don't satisfy the FK. Temporarily set foreign_key_checks=0 for the migration (MySQL) or backfill missing rows first. Better: add the FK as nullable and clean up orphaned data before enforcing.
Symptom · 04
Class "CreateArticlesTable" not found when running migrate:rollback.
Fix
The migration file was deleted after being run. You need to create a new migration that reverses the change. If you must roll back, recreate the original file from version control, run migrate:rollback, then delete it again. Never delete migration files that have been run on any environment.
Symptom · 05
Target class [some_migration] does not exist when using --path.
Fix
The --path option loads a single migration file, but its class may use an anonymous class name that conflicts with already loaded migrations. Use --path sparingly or register the migration manually via $this->call() in a seeder.
★ Migration Debugging Cheat SheetQuick commands and fixes for the most urgent migration problems.
Migration file exists but isn't picked up.
Immediate action
Check filename order. Ensure it's not already in `migrations` table.
Commands
php artisan migrate:status
SELECT * FROM migrations ORDER BY batch DESC, migration DESC;
Fix now
If the file is missing from the table but you need to run it, run php artisan make:migration again with the intended change or manually insert a placeholder record.
Rollback fails with constraint error.+
Immediate action
Identify which FK is blocking. Run `SHOW ENGINE INNODB STATUS` (MySQL) or check constraint names.
Commands
php artisan db:show --constraints
ALTER TABLE your_table DROP FOREIGN KEY fk_name;
Fix now
Temporarily disable FK checks: SET FOREIGN_KEY_CHECKS=0; then run rollback, then re-enable. But fix the down() method permanently.
`migrate:fresh` dropped all tables but then migration fails on some statement.+
Immediate action
Check the last successful migration in the output. Identify the failing SQL. It's often a duplicate column or an index that already exists.
Commands
php artisan migrate:fresh --pretend --seed > migrate_debug.sql
grep -n "ERROR" migrate_debug.sql
Fix now
Edit the failing migration to add if not exists logic or check existence via Schema::hasColumn() / Schema::hasIndex().
Command Comparison
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

1
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.
2
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.
3
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.
4
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.
5
Test your migrations in CI on a fresh database. A failing migration should block the deploy pipeline.
6
For zero-downtime schema changes, use the 3-step pattern
nullable → backfill → constraints.

Common mistakes to avoid

5 patterns
×

Editing a migration that's already been run by teammates

Symptom
The migrate command won't re-run the edited file because its filename already exists in the migrations table. Your changes are silently ignored — the schema never gets updated.
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. Never modify a file that has been run on any environment other than your local dev.
×

Writing a `down()` method that doesn't actually reverse `up()`

Symptom
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. The rollback may fail half-way, leaving the database in an inconsistent state.
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. Test with migrate:refresh before pushing.
×

Using `migrate:fresh` on a staging environment shared with clients or QA

Symptom
It drops every table instantly, with no warning, destroying all test data that's been entered. The --force flag makes it worse by suppressing the confirmation prompt. The team loses hours of manual test data.
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, make a database backup first, then use php artisan db:wipe preceded by a dump.
×

Adding a NOT NULL column to a large table without a default

Symptom
The migration tries to scan every row and fill the column with NULL, but the column is defined as NOT NULL. MySQL/PostgreSQL will either throw an error or lock the table for a long time while trying to backfill, potentially causing downtime.
Fix
Add the column as nullable first, then backfill data using a separate job, and finally alter the column to NOT NULL. See the zero-downtime pattern in the Squashing Migrations section.
×

Forgetting `constrained()` on foreign keys

Symptom
The foreign ID column is created, but no actual foreign key constraint exists. The database allows rows with IDs that don't exist in the parent table. Orphaned data accumulates silently, breaking application logic that assumes referential integrity.
Fix
Always pair foreignId() with constrained() unless you have a deliberate reason not to enforce the constraint. If you need the column without a constraint for performance reasons, document it explicitly.
INTERVIEW PREP · PRACTICE MODE

Interview Questions on This Topic

Q01SENIOR
What's the difference between `migrate:refresh` and `migrate:fresh`, and...
Q02SENIOR
If a colleague tells you they edited an existing migration file to fix a...
Q03SENIOR
How would you safely add a NOT NULL column to a table that already has m...
Q01 of 03SENIOR

What's the difference between `migrate:refresh` and `migrate:fresh`, and when would you choose one over the other in a team environment?

ANSWER
migrate:refresh rolls back all migrations by calling their down() methods, then runs up() on all of them. It tests your rollback logic. migrate:fresh drops all tables directly using DROP TABLE and then re-runs all migrations — it's faster but doesn't test down(). In a team environment, use refresh when you want to validate that rollback works (e.g., before a release). Use fresh only on your local machine for a quick clean slate. Never use fresh on shared staging because it destroys data without verification.
FAQ · 5 QUESTIONS

Frequently Asked Questions

01
What happens if I delete a migration file that has already been run?
02
Can I run a single specific migration instead of all pending ones?
03
What's the difference between `$table->string()` and `$table->text()` in migrations?
04
Is it safe to run `php artisan migrate` on production while traffic is live?
05
How can I resolve a 'Class not found' error during `migrate:rollback`?
N
Naren Founder & Principal Engineer

20+ years shipping production PHP systems at scale. Lessons pulled from things that broke in production.

Follow
Verified
production tested
May 24, 2026
last updated
1,554
articles · all by Naren
🔥

That's Laravel. Mark it forged?

8 min read · try the examples if you haven't

Previous
Laravel Blade Templates
6 / 15 · Laravel
Next
Laravel Middleware