Laravel Migrations — Drop Table Blocked by FK Constraint
Cannot drop table 'comments' because other objects depend on it — FK constraint on rollback.
20+ years shipping production PHP systems at scale. Lessons pulled from things that broke in production.
- Laravel migrations version-control database schema changes in timestamped PHP files.
- Each migration has
up()anddown()methods for forward/backward changes. - Execution order determined by filename timestamps; tracked in
migrationstable by batch. down()is critical for rollbacks; skip it and your rollback breaks in production.migrate:freshdrops all tables and re-runs — never use on shared environments.- Foreign keys require
constrained(); without it, referential integrity isn't enforced.
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 and up() 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.down()
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.
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.down() method — rollbacks are not optional.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 on every migration in that batch — in reverse order. So if you migrated three files in one down()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.
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 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 constrained()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.
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.constrained() is the top foreign key mistake. The column is created, but no FK constraint exists — orphaned rows sneak in silently.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 methods) and re-runs all migrations. It's faster than down()refresh because it skips the rollback logic, but it means your methods are never tested. That's why you should run down()migrate:refresh occasionally — it proves your implementations actually work.down()
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.
migrations table won't match your file — and migrate will simply never run your fix because it thinks it already did.down().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.
->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.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 method that looks correct might crash on rollback because of a subtle constraint difference.down()
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:
doesn't reversedown()— the refresh step detects this.up() - 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.
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.down() that crashes on rollback.up()/down() is non-negotiable.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 method. This guarantees those rows exist the instant the table is created. Use up()DB::table() with updateOrInsert() to make it idempotent.
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.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.
php artisan migrate --pretend first to validate the order.Foreign Key Constraint Fails on Midnight Rollback
php artisan migrate:rollback throws: Cannot drop table 'comments' because other objects depend on it or a similar foreign key constraint error.Schema::dropIfExists('comments') would cleanly remove the table regardless of constraints.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.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.- Always mirror your
logic inup()in reverse order: indexes, then foreign keys, then columns, then tables.down() - Test
migrate:refreshon a clone of production data before deploying. - Never assume
works — rundown()migrate:rollbackin staging at least once per release.
php artisan migrate reporting no pending migrations.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.SQLSTATE[42S01]: Base table or view already exists when running a migration.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.SQLSTATE[23000]: Integrity constraint violation: 1452 Cannot add or update a child row during a migration adding a foreign key.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.Class "CreateArticlesTable" not found when running migrate:rollback.migrate:rollback, then delete it again. Never delete migration files that have been run on any environment.Target class [some_migration] does not exist when using --path.--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.php artisan migrate:statusSELECT * FROM migrations ORDER BY batch DESC, migration DESC;php artisan make:migration again with the intended change or manually insert a placeholder record.Key takeaways
migrations table tracks which files have run and groups them by batchmigrate:rollback will undo.down() isn't optional boilerplatedown() 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 thingsconstrained(), you just have an unguarded integer column — referential integrity is not enforced.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
5 patternsEditing a migration that's already been run by teammates
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.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()`
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.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
--force flag makes it worse by suppressing the confirmation prompt. The team loses hours of manual test data.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
Forgetting `constrained()` on foreign keys
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
What's the difference between `migrate:refresh` and `migrate:fresh`, and when would you choose one over the other in a team environment?
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.Frequently Asked Questions
20+ years shipping production PHP systems at scale. Lessons pulled from things that broke in production.
That's Laravel. Mark it forged?
8 min read · try the examples if you haven't