Intermediate 6 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
Plain-English first. Then code. Then the interview question.
About
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.

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.

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.

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.

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.

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.

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

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

Common Mistakes to Avoid

  • 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 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?Mid-levelReveal
    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.
  • 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?SeniorReveal
    Editing a migration that has already been run on any environment (even local) means the migrations table thinks it's already executed. The edit will never run again. This causes silent schema drift: the developer's local database gets the fix, but everyone else's doesn't. Worse, if the file was run on staging or production, the bug persists. The correct approach is to create a new migration that applies the correction. If the environment has not yet deployed the buggy migration, you can roll it back, edit, then re-run — but only if you're the only developer using that environment.
  • QHow would you safely add a NOT NULL column to a table that already has millions of rows in production without causing downtime?SeniorReveal
    Use a multi-step migration: 1) Add the column as nullable — this is instant, no table lock. 2) Backfill existing rows with a default value using a queued job that processes records in chunks. 3) After backfill completes, run a migration that changes the column to NOT NULL with a default. Each step can be deployed separately, minimising risk. The key is never to add a non-nullable column in a single step on a large table, as that would lock the table and potentially cause a full table rebuild.

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.

Is it safe to run `php artisan migrate` on production while traffic is live?

Usually yes, as long as the migration doesn't lock tables for extended periods. Adding a column as nullable is safe. Adding a NOT NULL column, dropping a column, or altering a column with ->change() can lock the table for the duration of the operation. For high-traffic applications, use zero-downtime patterns and consider running migrations during maintenance windows or off-peak hours.

How can I resolve a 'Class not found' error during `migrate:rollback`?

This error means the migration file for a class that was previously run no longer exists. To fix, restore the missing file from your version control to the same path. Then run migrate:rollback again. After the rollback, you may delete the restored file if desired. Avoid deleting migration files permanently if there's any chance of rollback.

🔥

That's Laravel. Mark it forged?

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

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