Doctrine Migrations: A Practical Guide to Versioning Your Database Schema
When a PHP app hits production, changing the database can feel risky. Doctrine Migrations turns schema evolution into a repeatable, safe process. This post walks through migration files, the CLI lifecycle, a hands‑on example, and real‑world trade‑offs.
15 Jun 2026, 02:44 UTC

Problem: Schema Changes in Production
In a live PHP application, a new feature often requires a new column, an index, or a table drop. Without a disciplined process, developers risk:
- Applying SQL manually and forgetting a step.
- Running the same change twice, causing errors.
- Deploying a broken schema that only shows up in production.
These risks grow with team size and database complexity. A reliable, repeatable mechanism is essential.
Thesis: Doctrine Migrations as a Reliable Solution
Doctrine Migrations provides a structured workflow:
- Schema changes are expressed as PHP classes.
- Each class has
up()anddown()methods for forward and reverse changes. - Migrations are tracked in a dedicated
migration_versionstable. - CLI commands apply, revert, or list migrations.
Because migrations are code‑first, they integrate with version control, CI pipelines, and the ORM’s metadata. The result is a reproducible history that can be replayed on any environment.
Migration Files: The Core Unit
When you run php bin/console doctrine:migrations:generate, Doctrine creates a PHP file in src/Migrations with a class name like M20261008120000_AddUserEmail. The file looks like:
addSql('ALTER TABLE user ADD email VARCHAR(255) NOT NULL');
$this->addSql('CREATE INDEX IDX_USER_EMAIL ON user (email)');
}
public function down(): void
{
$this->addSql('DROP INDEX IDX_USER_EMAIL ON user');
$this->addSql('ALTER TABLE user DROP email');
}
}
Key points:
up()applies the change;down()undoes it.- All SQL is wrapped in
$this->addSql()calls, which the migration runner executes in order. - When the migration is executed, Doctrine records its version number in
migration_versions. Future runs skip this migration unless--dry-runor--forceis used.
Lifecycle Commands
The Symfony Console integration exposes several useful commands. Below is a quick reference. Run these from the project root, and ensure the database credentials in .env point to the target environment.
php bin/console doctrine:migrations:generate– scaffold a new migration.php bin/console doctrine:migrations:migrate– apply all pending migrations.php bin/console doctrine:migrations:status– list executed and pending migrations.php bin/console doctrine:migrations:execute {version} --up– run a single migration.php bin/console doctrine:migrations:execute {version} --down– revert a single migration.
Example: to apply the migration we just created, run:
php bin/console doctrine:migrations:migrate
Doctrine will prompt for confirmation. After acceptance, it will:
- Begin a transaction (configurable via
doctrine_migrations.yaml). - Execute the
up()statements. - Record the version in
migration_versions. - Commit the transaction.
Worked Example: Adding a Nullable Column with a Default
Assume we need to add a last_login_at timestamp that defaults to NULL and is indexed. Steps:
- Generate migration:
php bin/console doctrine:migrations:generate # Output: src/Migrations/M20261008123000_AddLastLoginAtToUser.php - Edit the file:
public function up(): void { $this->addSql('ALTER TABLE user ADD last_login_at DATETIME DEFAULT NULL'); $this->addSql('CREATE INDEX IDX_USER_LAST_LOGIN_AT ON user (last_login_at)'); } public function down(): void { $this->addSql('DROP INDEX IDX_USER_LAST_LOGIN_AT ON user'); $this->addSql('ALTER TABLE user DROP last_login_at'); } - Run migration:
php bin/console doctrine:migrations:migrate - Verify:
php bin/console doctrine:migrations:status # Expected: 1 pending migration, 1 executed # Check schema php bin/console doctrine:schema:validate # Should report no errors - Check the migrations table:
SELECT * FROM migration_versions; # Should contain a row with version M20261008123000 and executed_at timestamp
Rollback (if needed):
php bin/console doctrine:migrations:execute M20261008123000 --down
Trade‑offs & Limitations
- Editing Deployed Migrations: Once a migration is executed in production, never modify its file. Instead, create a new migration that reverses or extends the change. Editing can break the version history and cause
migration_versionsto become inconsistent. - Long‑Running Migrations: Adding many columns or large data transformations can lock tables. Use transactions and run in a staging environment first. Consider splitting complex changes into multiple migrations.
- Missing Migrations Table: If the
migration_versionstable is lost, Doctrine cannot track state. Back it up or protect it with database privileges. - Version Conflicts: With multiple developers, two migrations may receive the same timestamp‑based version. Coordinate naming or use
--no-interactionto force unique versions.
Actionable Closing
To adopt Doctrine Migrations effectively:
- Configure
doctrine_migrations.yamlto run migrations in a transaction and set a sensibletable_name. - Add a pre‑commit hook that runs
php bin/console doctrine:migrations:statusto catch pending migrations before pushing. - Document the migration workflow in your team’s README, including the rule “never edit a deployed migration.”
- Integrate migration runs into your CI pipeline: run
migrateagainst a fresh test database, thenvalidateto ensure no schema drift. - When deploying, run migrations with
--no-interactionin production to avoid manual confirmation.
With these practices, Doctrine Migrations turns database evolution from a fragile, manual chore into a robust, versioned, and auditable process.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.