Laravel Migrations — Drop Table Blocked by FK Constraint
Cannot drop table 'comments' because other objects depend on it — FK constraint on rollback.
- 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.
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 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.
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.
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.
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 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.
| Command | What It Does | Destroys Data? | Tests down()? | Best Used When |
|---|---|---|---|---|
| migrate | Runs pending migrations only | No | No | Normal development and production deploys |
| migrate:rollback | Undoes the last batch only | Yes — last batch | Yes | Fixing a mistake in the migration you just ran |
| migrate:refresh | Rolls back all, then re-runs all | Yes — everything | Yes | You need a clean DB but want to verify rollback logic |
| migrate:fresh | Drops all tables, then runs all migrations | Yes — everything | No | Fastest clean slate in local dev — CI pipeline resets |
| migrate --pretend | Prints SQL without executing it | No | No | Auditing migrations before a production deploy |
| schema:dump | Squashes all migrations into one SQL file | No | No | Large projects with 50+ migrations slowing down CI |
Key Takeaways
- The
migrationstable tracks which files have run and groups them bybatch— understanding batch numbers is the key to predicting whatmigrate:rollbackwill undo. isn't optional boilerplate — it's your safety net. Adown()that doesn't cleanly reversedown()will fail loudly in the worst possible moment: a midnight rollback on production.up()foreignId('user_id')->constrained()does two things: creates the column AND the foreign key constraint. Without, you just have an unguarded integer column — referential integrity is not enforced.constrained()- Never run
migrate:freshon any shared environment. It has no confirmation for existing data and no way to undo it.migrate:freshis 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. Usephp artisan make:migration alter_articles_add_missing_indexand 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 ofas a mirror image ofdown()executed in reverse. Drop indexes before dropping columns, and drop foreign keys before dropping the columns that hold them. Test withup()migrate:refreshbefore 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: Reservemigrate:freshfor your local machine only. On shared environments, always usemigratefor new changes. If a reset is truly needed, get explicit sign-off, make a database backup first, then usephp artisan db:wipepreceded 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 pairforeignId()withunless you have a deliberate reason not to enforce the constraint. If you need the column without a constraint for performance reasons, document it explicitly.constrained()
Interview Questions on This Topic
- QWhat's the difference between
migrate:refreshandmigrate:fresh, and when would you choose one over the other in a team environment?Mid-levelReveal - 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
- QHow would you safely add a NOT NULL column to a table that already has millions of rows in production without causing downtime?SeniorReveal
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 can't be called on a missing file. The entry in the down()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 maps to string()VARCHAR with a default max of 255 characters and can be indexed (great for searchable fields like slugs or emails). maps to a text()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 for short, searchable values and string() for long-form content like article bodies.text()
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