Symfony Database Migrations: A Complete Guide to Safe Schema Changes
Every Symfony application eventually needs a schema change, and every schema change is a chance to take production down. A symfony database migration that looks harmless in a local environment, a new NOT NULL column, a renamed field, an index that seemed cheap, can lock a table for minutes on a dataset that's grown far past what anyone tested against. This guide covers the full workflow: how Doctrine Migrations actually work, what to check before a migration ships, how to change schema without downtime, and what to do when a migration has already run against production but the deploy still failed.
How Doctrine Migrations work
Doctrine Migrations tracks schema changes as versioned PHP classes, each with an up() method that applies a change and a down() method that reverses it. Symfony stores which versions have run in a doctrine_migration_versions table, so the tool always knows the current state of the database relative to your codebase.
There are two ways to create a migration. doctrine:migrations:diff compares your Doctrine entity mappings against the live schema and generates a migration file automatically. It's fast, but it also generates exactly what it sees: if an entity change implies a data transformation, not just a structural one, the generated file won't include it. The second option is writing the migration by hand, which is the right call whenever a change needs custom SQL, a data backfill, or logic that Doctrine's schema comparator can't infer on its own.
Whichever path you take, every migration needs to be idempotent. If a deploy fails partway through and gets re-run, or if a migration is accidentally executed twice against the same database, it should not error out or corrupt data. Wrapping ALTER TABLE statements in existence checks, and guarding data migrations with WHERE clauses that make re-running safe, costs a few extra minutes and prevents a much worse afternoon.
The pre-deployment checklist
Before any migration reaches production, three things should already be true.
The migration has been tested against a copy of production data, not a seeded local database with a few hundred rows. Query planners behave differently at scale, and a migration that runs instantly against 500 rows can take twenty minutes against 50 million. If you don't have a routine way to test against production-sized data, that gap is worth closing before it costs you a maintenance window.
A rollback migration exists and has actually been run once, not just written. down() methods rot quietly: someone writes one, it's never executed, and by the time it's needed six months later it references a column that no longer exists. Running the rollback in a staging environment as part of the same change catches this before it matters.
You have an estimate for how long the migration will take on production-sized tables. This isn't about precision, it's about knowing whether you're looking at a two-second ALTER TABLE or a ten-minute table rewrite that needs to be scheduled outside peak traffic. Run EXPLAIN on the underlying query, check the table's row count, and if the answer is "we don't know," that's the answer to fix first.
Zero-downtime patterns for common changes
Not every schema change carries the same risk, and treating them all the same way either slows down harmless changes or lets risky ones through unchecked.
Adding a nullable column is close to free on PostgreSQL and MySQL 8, since the database doesn't need to rewrite existing rows when a NULL default requires no backfill. Adding a NOT NULL column is a different problem. Older MySQL versions rewrite the entire table to populate the new default value, which locks writes for the duration. The safer sequence is to add the column as nullable, backfill it in batches, then add the NOT NULL constraint once every row has a value.
Renaming a column should never be a single migration if the table is under active use, because any deploy that isn't instant leaves old code writing to a column name that no longer exists. The expand-contract pattern avoids this: add the new column, deploy application code that writes to both the old and new column, backfill existing rows, deploy code that reads only from the new column, and only then, in a later migration, drop the old one. It's more steps, but each step is reversible on its own.
Adding an index without locking the table depends on whether your database engine supports it. PostgreSQL's CREATE INDEX CONCURRENTLY builds the index without holding a lock that blocks writes, at the cost of a longer build time and the fact that it can't run inside a transaction, which matters for how you structure the Doctrine migration itself. MySQL's ALGORITHM=INPLACE behaves similarly for most index types on modern versions. Check what your engine actually supports before assuming an index add is safe by default.
Testing migrations in CI
A migration that only gets tested by a developer running it locally is a migration that hasn't really been tested. CI should run every pending migration against a real PostgreSQL or MySQL instance, matching production as closely as practical, rather than against SQLite. SQLite's type system and transaction behavior differ enough from production databases that a migration passing in SQLite tells you very little about whether it will pass against the real thing.
The practical setup: spin up a database service in your CI pipeline, run doctrine:migrations:migrate against a fresh instance built from the existing schema, and fail the build if the migration errors, times out, or leaves the schema in an unexpected state. This catches the class of bug that only shows up when a migration runs against a database that already has data in it, which is most of them.
Deploying migrations with Kamal
If you're deploying with Kamal, migrations belong in a pre-deploy hook, running against the current release before traffic shifts to the new version. This ordering matters: for an expand-contract change, the schema needs to support both the old and new application code during the deploy window, so the migration has to complete before the new code goes live, and the old code needs to keep working against the updated schema until the rollout finishes.
Kamal's hooks run as shell commands tied to specific deploy stages, so a pre-deploy hook that runs bin/console doctrine:migrations:migrate --no-interaction gives you a single, repeatable step instead of a manual command someone has to remember to run. Pair that with a health check that fails the deploy if the migration errors, rather than letting a broken schema reach production with the assumption that someone will notice.
When a migration has already run and the deploy fails
This is the scenario the checklist above is meant to prevent, and it still happens. The migration completed against production, the version table shows it as applied, but something else in the deploy failed: a container that won't start, a dependent service that's now incompatible with the new schema, a code path that assumed the migration would also update application logic that's still on the old version.
The first step is confirming what actually happened at the database level, not assuming. Check doctrine_migration_versions to see which version is currently recorded as applied. Then decide whether to roll forward or back. Rolling forward, fixing whatever broke and deploying the corrected code, is almost always safer than rolling back a schema change that other parts of the system may already be relying on. Running the down() migration against a production database that's actively serving traffic on the new schema can cause more damage than the original failure. If a rollback is genuinely the right call, running it in a maintenance window with traffic paused is worth the extra minutes it costs.
Getting this right consistently
The pattern across all of this is the same: schema changes are safe when they're reversible, tested against realistic data, and small enough that any single step can fail without taking the database down with it. That discipline is easier to build into a new codebase than to retrofit into one where migrations have accumulated without a consistent process for years.
If your team is dealing with a Symfony application where migrations have become a source of anxiety rather than routine maintenance, that's usually a sign the underlying process needs attention, not just the next migration. Wolf-Tech works with teams on exactly this kind of problem, through code quality consulting to review how migrations, testing, and deployment fit together, and through legacy code optimization when years of ad-hoc schema changes need to be untangled before they can be trusted again.
If you want a second opinion on your migration workflow or you're planning a schema change you're not confident about, reach out at hello@wolf-tech.io or find more at wolf-tech.io.

