A schema migration is a deceptively small piece of code with an outsized ability to cause damage — a single ALTER TABLE on a large production table can lock writes for minutes, and a migration that drops a column with no rollback plan can mean unrecoverable data loss the moment it runs. Migrations deserve the same care as any other production-affecting code change, not the "just run it" treatment they often get.
Migrations Are Forward and Backward
Every migration should have a working down() method, even though it is tempting to skip it when a migration only seems to move forward. The down() method is your rollback plan when a migration ships a problem in production — without it, fixing a bad migration means writing an emergency fix under pressure instead of running a command you already tested.
public function up(): void
{
Schema::table('orders', function (Blueprint $table) {
$table->string('status')->default('pending')->after('id');
});
}
public function down(): void
{
Schema::table('orders', function (Blueprint $table) {
$table->dropColumn('status');
});
}Large Tables Need Lock-Aware Migrations
Adding a column with a default value, adding an index, or changing a column type can each lock an entire table for the duration of the operation on some database engines and table sizes — acceptable on a table with a thousand rows, potentially catastrophic on one with fifty million rows actively receiving writes. Before running a migration against a large production table, know specifically what kind of lock the operation takes on your actual database engine and version, not just whether the migration "works" on a small local copy.
For genuinely large tables, the safer pattern is often: add a nullable column with no default (fast, minimal lock), backfill the data in batches via a separate script with throttling between batches, then add a NOT NULL constraint only once backfill is confirmed complete.
Never Edit a Migration That Has Already Run in Production
Editing an already-applied migration file works fine on your own machine, where the migrations table has no record of needing to re-run it — but it creates silent drift between environments. A teammate's fresh database, or a new staging environment, runs the edited version and ends up with a different schema than production, which already ran the original. If a migration shipped with a mistake, write a new migration to fix it; never edit history that has already been applied somewhere real.
Test Migrations Against a Production-Like Copy
A migration that runs cleanly against an empty local database can still fail against production data — a unique constraint you're adding might conflict with existing duplicate rows nobody knew existed, or a foreign key you're adding might reference rows that do not actually all exist. Testing migrations against a recent, sanitized copy of production data, not just an empty schema, catches this entire category of failure before it reaches the real environment.
A Migration Checklist Before Running Against Production
The down() method is written and has actually been tested, not just present. The migration has been run against a realistic data volume, not just an empty table. Any operation known to lock large tables has a specific mitigation plan (off-peak timing, batched backfill). A rollback plan exists beyond just the down() method, in case the migration partially applies before failing. Someone other than the author has reviewed the migration specifically for its production impact, not just its correctness.
Zero-Downtime Migrations for Renaming a Column
Renaming a column directly breaks any code still deployed that references the old name, even briefly during a rolling deploy. The safer multi-step pattern: add the new column, write to both old and new columns simultaneously in application code, backfill existing rows, switch reads to the new column, stop writing to the old column, and only then drop it — each step deployable and reversible independently, with no moment where any deployed version of the code is broken.
Migrations and Seeders Are Different Things
A migration changes schema structure; a seeder populates data. Mixing the two — inserting reference data directly inside a migration's up() method — works until that migration needs to run again against a database that already has that data, causing a duplicate-key failure on a re-run or a fresh environment setup. Keep reference-data population in dedicated seeders, run independently from schema migrations.
Foreign Keys and Migration Order
A migration adding a foreign key constraint will fail if the referenced table or column does not yet exist at the point that migration runs, which matters specifically when migrations are not guaranteed to run in the order their filenames might suggest across different deployment histories. Laravel orders migrations by timestamp in the filename, so a foreign-key migration must have a later timestamp than the migration creating its referenced table — an easy mistake when migrations are written out of order and only reordered later.
Squashing Migrations for a Mature Project
A project with several years of history can accumulate hundreds of migration files, slowing down fresh environment setup and test database creation considerably. Squashing — generating a single schema dump representing current state and archiving old migration files — speeds this up, but should only be done for environments that have no need to replay historical migration steps individually, since squashing discards the step-by-step history those files represented.
Case Study: The Migration That Locked Production for Eleven Minutes
A team added a NOT NULL column with a default value to a forty-million-row orders table during business hours, expecting the operation to take a few seconds based on testing against a local database with a few thousand rows. In production, the ALTER TABLE locked the table for the full duration of rewriting every row, roughly eleven minutes, during which every write to orders queued up and timed out, and the checkout flow was effectively down. The postmortem's fix was procedural: any migration touching a table over a defined row-count threshold now requires an explicit lock-duration estimate against a production-sized copy before it is allowed to run against real production, and large additive changes use the nullable-then-backfill-then-constrain pattern by default rather than as an exception.
A Glossary for This Topic
Migration: a versioned, code-defined change to database schema. Rollback (down()): the reverse operation undoing a migration's change. Backfill: populating a new column's values for existing rows after the column itself has been added. Lock: a database mechanism preventing concurrent operations from conflicting, which can block other queries for its duration. Idempotent migration: one safe to run multiple times without error or unintended duplicate effect.
Frequently Asked Questions
Can I just run migrations during a maintenance window? For genuinely risky large-table changes, yes, a planned low-traffic window meaningfully reduces blast radius even if it does not eliminate the underlying lock duration.
Should every migration be reversible? Ideally yes; a small number of migrations (an irreversible data transformation) are legitimate exceptions, but they should be rare and explicitly called out, not the default.
How do I know if a migration will lock a table? It depends on the specific database engine, version, and operation — test against a production-sized copy and consult your database engine's documentation for the specific operation, rather than guessing from a small local table.
Step-by-Step: Safely Adding a Required Column to a Large Table
First, add the column as nullable with no default, which is typically a fast, low-lock operation even on a very large table. Second, deploy application code that writes the new column on every new and updated row, while still tolerating null for existing rows. Third, run a throttled backfill script in batches, populating the column for existing rows without locking the table for an extended single operation. Fourth, verify every row has a value with a count query. Fifth, only then add the NOT NULL constraint, which on most engines is now a much smaller, faster operation since the data already satisfies it.
A Comparison Table: Migration Risk by Operation Type
| Operation | Typical Lock Risk | Safer Alternative |
|---|---|---|
| Add nullable column | Low | Usually safe as-is |
| Add NOT NULL column with default | High on large tables | Nullable, backfill, then constrain |
| Add index | Medium-high | Online/concurrent index build if available |
| Rename column | Breaks live code | Add-write-both-switch-drop pattern |
Security Considerations Checklist
Never run migrations with database credentials broader than they need — a migration user should be able to alter schema but does not necessarily need the same access as your application's runtime database user. Review any migration touching a table with sensitive data (encrypting a column, changing how a credential is stored) for whether old data needs explicit migration too, not just new schema structure. Be cautious with migrations that run raw SQL directly, since they bypass the query builder's usual parameter binding and can reintroduce injection risk if string-interpolating any external input.
Accessibility Considerations
Schema design has no direct accessibility dimension, but it can constrain accessibility features built on top of it — a schema with no way to store a user's preferred reduced-motion setting, alt-text overrides, or font-size preference makes accessibility features harder to implement later than designing reasonable extensibility into user-preference tables from the start.
How This Plays Out at Different Scales
A small project with a handful of tables and low traffic can usually run migrations directly with minimal ceremony. A growing application with meaningful production traffic needs the lock-aware patterns and production-data testing described earlier, since the cost of getting this wrong (as in the case study above) grows directly with table size and traffic. A large, mature system typically needs a formal migration review process and dedicated tooling for safely managing schema change across many tables and a long migration history.
What to Do When You Inherit a Project With No Migration History at All
Inheriting a project where the schema was built up manually over time with no migration files tracking how it got there is a common, awkward starting point. The first step is not writing migrations retroactively for every historical change, which is usually not worth the effort and risks introducing subtle differences from the real current schema; instead, generate a single baseline migration reflecting the current actual schema exactly as it exists in production, treat that as your starting point going forward, and write every future change as a proper migration from that point on.
Final Checklist Before Trusting a Migration Workflow
Every schema change going forward is captured as a migration, with no manual, undocumented production schema changes happening outside that process. Every migration has a tested, working down() method or an explicitly documented reason it cannot have one. Large-table changes have a specific lock-risk assessment before running against production. Migrations have been tested against a production-sized data copy, not just an empty schema. No migration has been edited after running in any real environment.
Closing Thought, Revisited
A migration is a small piece of code carrying outsized risk specifically because it touches the one thing an application absolutely cannot lose or corrupt: its data. Treating every migration with the seriousness of a production deploy — tested, reviewed, reversible, and run with a specific plan for what large or risky operations actually do under load — is what keeps schema evolution from becoming the source of your worst incidents.
Database-Specific Migration Quirks
MySQL and PostgreSQL handle the same logical operation differently at the lock level — an operation that is fast and low-lock on one engine can be a full-table rewrite on another, and behavior can even differ between major versions of the same engine. Never assume a migration pattern that was safe on a previous project's database engine and version automatically transfers to a different one; verify against the specific engine and version your production database actually runs.
Multi-Database Migrations
An application reading from a replica and writing to a primary needs migrations applied to the primary, with replication lag accounted for — application code deployed alongside a migration that immediately reads from a replica can see a stale, pre-migration schema for a brief window until replication catches up, which matters specifically for any code path that cannot tolerate even a short inconsistency.
Communicating Schema Changes to the Rest of the Team
A migration that changes a column's meaning or a table's relationships can break code elsewhere in a large codebase that nobody on the migration author's team realized depended on the old structure. Announcing non-trivial schema changes in a shared channel before merging, not just relying on code review to catch every downstream dependency, gives other teams a chance to flag a dependency the migration author could not reasonably have known about.
Generated and Computed Columns
Some databases support generated columns, computed automatically from other columns rather than written directly by application code. These can simplify queries that would otherwise need repeated computed expressions, but they add a layer of schema behavior that is easy to forget exists when reasoning about why a value changed, since no application code line explicitly set it.
Soft Deletes and Migration Interplay
Adding soft deletes to an existing table is itself a migration (adding a deleted_at column) that changes how every existing query against that table behaves once the application code starts applying the global soft-delete scope — a query that previously returned all rows will silently exclude "deleted" ones afterward. Treat enabling soft deletes on a mature table as a behavior change requiring the same review as any other migration, not just a column addition.
Schema Documentation as Code
Comments embedded directly in migration files explaining why a non-obvious change was made (a specific business rule driving a constraint, a known edge case a default value works around) age far better than the same explanation living only in a pull request description or a chat message, since the migration file is what future developers will actually be reading when they need to understand the schema's history months or years later.
Avoiding Migration Conflicts on Shared Branches
Two developers adding migrations with the same timestamp prefix on separate branches can create subtle ordering conflicts once both branches merge, since the actual run order may not match either developer's original intent. Regenerating a migration's timestamp at merge time, or adopting a team convention for resolving these collisions deliberately rather than leaving order to chance, avoids a class of bug that is easy to miss in code review since the migrations look correct individually.