Doctrine Migrations Best Practices
AI generated
SF
{ }
Symfony · Doctrine Migrations · Database · DevOps
Doctrine Migrations Best Practices
Safe schema changes without downtime

A blindly executed migration diff can cause data loss, table locks or downtime. With the expand-contract pattern, additive migrations and consistent CI checks, Doctrine Migrations become predictable, symmetric and production safe.

19 min read Migrations · Expand-Contract · Zero Downtime · CI Symfony 7 · Doctrine Migrations 3 · MySQL 8

1. Why not to blindly trust migration diffs

The doctrine:migrations:diff command automatically generates a migration from the difference between the current entity mapping and the actual database schema. This automation is why Doctrine Migrations are so popular, but it carries a systematic risk: the diff generator knows no business intent, only structural differences. A renamed column is almost always interpreted by the generator as DROP COLUMN followed by ADD COLUMN, not as RENAME COLUMN. The result: all data in that column is lost, even though the intent was merely a rename.

The first and most important step for any generated migration is therefore to run it with --dry-run and read the generated SQL code line by line before applying it to a real database. This discipline is not optional with Doctrine Migrations, it is the only reliable safeguard against automatically generated but semantically wrong schema changes. Anyone who accepts migrations straight from the diff and runs them directly in production risks exactly the kind of data loss that migrations are supposed to prevent.

A second aspect concerns implicit behavior changes: adding a NOT NULL column without a default value to an already populated table fails immediately in MySQL strict mode, while in older configurations it silently fills the column with an implicit default. Both behaviors need to be checked with Doctrine Migrations before the migration runs against an environment with real production data, not after.

2. Safe migrations: writing up and down symmetrically

Every migration in Doctrine Migrations consists of an up() and a down() method. The down() method is supposed to restore the exact state before the migration, but in practice it is often neglected or left with an empty comment. That comes back to bite you as soon as a migration turns out to be broken in production and must be rolled back while application code has already been deployed against the new schema in parallel.


<?php

declare(strict_types=1);

namespace DoctrineMigrations;

use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;

final class Version20260730120000 extends AbstractMigration
{
    public function getDescription(): string
    {
        return 'Add nullable "sku" column to product table';
    }

    public function up(Schema $schema): void
    {
        $this->addSql('ALTER TABLE product ADD sku VARCHAR(64) DEFAULT NULL');
        $this->addSql('CREATE INDEX idx_product_sku ON product (sku)');
    }

    public function down(Schema $schema): void
    {
        $this->addSql('DROP INDEX idx_product_sku ON product');
        $this->addSql('ALTER TABLE product DROP sku');
    }
}

An important rule with Doctrine Migrations: every migration should be able to run inside exactly one transaction, as long as the database system supports transactional DDL. PostgreSQL supports this fully, MySQL only partially, since certain DDL commands trigger implicit commits. For MySQL, that means splitting larger migrations into several smaller, independently applicable steps, instead of relying on a transaction that does not actually exist in that form.

3. Zero downtime with the expand-contract pattern

The expand-contract pattern is the central technique for applying Doctrine Migrations without downtime to applications with multiple deployments running concurrently. Instead of renaming a column or changing its type in a single step, the change is split into three independent deployments: expand adds the new structure additively without removing the old one. Migrate writes application code that populates or reads both structures in parallel. Contract only removes the old structure after all old code has been removed.

This split is necessary because during rolling deployments, old and new application versions briefly access the same database concurrently. A Doctrine Migration that renames a column in one step immediately breaks the old code that still references the original column. With expand-contract, the schema stays compatible with both code versions throughout the entire rollout phase.


<?php

declare(strict_types=1);

namespace DoctrineMigrations;

use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;

// EXPAND migration: add new column alongside the old one, additive only
final class Version20260730130000 extends AbstractMigration
{
    public function getDescription(): string
    {
        return 'Expand: add new "price_cents" column, keep legacy "price" column';
    }

    public function up(Schema $schema): void
    {
        $this->addSql('ALTER TABLE product ADD price_cents INT DEFAULT NULL');
        // Backfill in small batches, not in a single UPDATE — see section 5
        $this->addSql(
            'UPDATE product SET price_cents = ROUND(price * 100) WHERE price_cents IS NULL LIMIT 5000'
        );
    }

    public function down(Schema $schema): void
    {
        $this->addSql('ALTER TABLE product DROP price_cents');
    }
}

Only in a later, separate migration, after all code has been switched over to price_cents and deployed, follows the contract step with DROP COLUMN price. This temporal separation is the core of every zero downtime strategy with Doctrine Migrations.

4. Indexes on large tables without table locks

On tables with several million rows, a naive CREATE INDEX statement can lock the table for the duration of index creation, causing timeouts and request failures in production. MySQL with InnoDB has supported online DDL for many index operations since version 5.6, controlled via ALGORITHM=INPLACE and LOCK=NONE. A Doctrine Migration that sets these hints explicitly avoids the longer exclusive lock of the classic table copy approach.


<?php

declare(strict_types=1);

namespace DoctrineMigrations;

use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;

final class Version20260730140000 extends AbstractMigration
{
    public function getDescription(): string
    {
        return 'Add index on large orders table without locking it';
    }

    public function up(Schema $schema): void
    {
        // ALGORITHM=INPLACE avoids a full table copy, LOCK=NONE keeps writes flowing
        $this->addSql(
            'ALTER TABLE orders ADD INDEX idx_orders_customer_id (customer_id), ALGORITHM=INPLACE, LOCK=NONE'
        );
    }

    public function down(Schema $schema): void
    {
        $this->addSql('ALTER TABLE orders DROP INDEX idx_orders_customer_id, ALGORITHM=INPLACE, LOCK=NONE');
    }

    public function isTransactional(): bool
    {
        // Online DDL statements are not compatible with an implicit transaction wrapper
        return false;
    }
}

Important for Doctrine Migrations on MySQL: the isTransactional() method must return false if the migration contains online DDL statements, since these can otherwise collide with the migrations bundle's implicit transaction wrapper. PostgreSQL offers a comparable concept with CREATE INDEX CONCURRENTLY, which also must run outside a transaction.

5. Separating data migrations from schema changes

A large data migration that changes millions of rows in a single UPDATE statement blocks write access to the affected table for the entire duration of the statement and can dramatically increase replication lag on read replicas. Doctrine Migrations should therefore execute data changes in small, repeatable batches, with LIMIT clauses and a loop that briefly pauses progress between batches to let replication lag catch up.

For very large data volumes, where even a batched migration takes too long for a single deployment window, the right answer is to fully decouple the data migration from the schema migration and implement it as a separate, asynchronous process via the Messenger component. The Doctrine Migration itself then only contains the additive schema change, while a standalone command or a Messenger handler processes the actual data migration in the background, with progress tracking and error handling.

6. Versioning and organizing migration files

As a project grows, hundreds of migration files quickly accumulate, which noticeably slows down doctrine:migrations:migrate, because every single migration is checked at startup. Doctrine Migrations supports so called squashing: all migrations already applied in production are consolidated into a single schema definition, while the versions table in the database stays unchanged. New migrations then build on top of this consolidated baseline.

For teams with multiple parallel feature branches, a dedicated namespace per bounded context is also recommended, for example DoctrineMigrations\Billing and DoctrineMigrations\Catalog, configured via multiple migrations_paths entries in the bundle configuration. That significantly reduces merge conflicts between teams, since different teams work in different directories, even when both are creating new Doctrine Migrations at the same time.

7. CI integration and automated checks

The doctrine:migrations:status command shows whether all migrations have been applied and whether the current schema matches the existing mapping definitions. In a CI pipeline it is worth checking this command after every migration run and failing the build if a discrepancy is found. In addition, doctrine:schema:validate checks whether the entity mapping and the actual database schema are consistent after applying all Doctrine Migrations, which reliably catches forgotten or broken migration files.


# .gitlab-ci.yml — verify migrations before deploy
migration-check:
  stage: test
  script:
    - bin/console doctrine:database:create --if-not-exists --env=test
    - bin/console doctrine:migrations:migrate --no-interaction --env=test
    - bin/console doctrine:schema:validate --env=test
    - bin/console doctrine:migrations:status --env=test | grep -q "New Migrations: *0" || exit 1

These checks run on every merge request and make sure nobody accidentally commits entity mapping changes without an associated Doctrine Migration, an error that would otherwise only surface at the next deployment on a real environment.

8. Using rollback strategies correctly

A rollback via doctrine:migrations:migrate prev is only safe with Doctrine Migrations if the migration is truly reversible without producing data loss. A migration that drops a column can recreate the column in down(), but the data it previously contained is irrecoverably lost. For such destructive changes, a rollback in the classic sense is an illusion, not a real safety net.

In practice, the more reliable strategy with Doctrine Migrations is to perform destructive schema changes only after a waiting period of several days or weeks, after verifying that the old structure is really no longer needed, and to treat a current database backup as the actual rollback mechanism in the meantime. A rollback via the down() method works well for additive, nondestructive changes, but not as a universal safeguard against every kind of migration mistake.

9. Migration patterns compared directly

The following table contrasts different approaches to schema changes with Doctrine Migrations and shows which risk each carries and which pattern mitigates it.

Change type Risky approach Recommended pattern Benefit
Rename a column DROP + ADD (diff default) Expand-contract with a parallel column No data loss, no breaking change
Index on a large table CREATE INDEX (default lock) ALGORITHM=INPLACE, LOCK=NONE No exclusive table lock
Update millions of rows A single UPDATE statement Batched updates or a Messenger job No replication lag, interruptible
NOT NULL without a default Directly on a populated table Set a default first, then NOT NULL No failure under strict mode
Validate a migration Test directly in production --dry-run plus a CI pipeline check Errors visible before deployment

The table shows a consistent pattern: almost every risk with Doctrine Migrations stems from a single, large, synchronous operation. The solution is almost always the same, performing the change additively, in batches, and decoupled in time from application code, instead of forcing it through in a single destructive step.

Mironsoft

Symfony deployment, database migrations and zero downtime architecture

Need to deploy schema changes without downtime?

We set up expand-contract workflows, CI migration checks and batch processes for your data migrations, so deployments stay predictable even with tables holding millions of rows.

Migration audit

Review existing migrations for risks and missing down() methods

Zero downtime setup

Establish an expand-contract workflow for rolling deployments

CI integration

Automated migration checks in the deployment pipeline

10. Summary

Safe Doctrine Migrations do not come from blindly trusting the diff generator, but from systematic discipline: check every generated migration with --dry-run, keep up() and down() symmetric, and consistently split destructive changes across several time decoupled deployments using the expand-contract pattern. On large tables, ALGORITHM=INPLACE, LOCK=NONE prevents unnecessary locks, and data migrations belong in batched loops or asynchronous Messenger jobs, never in a single large UPDATE statement.

CI checks with doctrine:migrations:status and doctrine:schema:validate catch forgotten migrations before they become a problem in production. Rollback via down() is a useful tool for additive changes, but no substitute for a current database backup when destructive operations are involved. Applying these principles consistently to every Doctrine Migration significantly reduces the risk of downtime and data loss.

Doctrine Migrations best practices — the key facts at a glance

Check the diff

Read every generated migration with --dry-run before applying it. Renames otherwise become DROP plus ADD.

Expand-contract

Split destructive changes into expand, migrate and contract, for compatibility during rolling deployments.

Large tables

ALGORITHM=INPLACE, LOCK=NONE for indexes, batched UPDATE statements for data migrations.

CI enforcement

Enforce doctrine:migrations:status and doctrine:schema:validate in every pipeline.

11. FAQ: Doctrine Migrations Best Practices

1Why are generated migrations risky?
The diff generator interprets renames as DROP plus ADD, causing data loss. --dry-run is therefore mandatory.
2What is expand-contract?
Three steps: expand adds additively, migrate switches over the code, contract removes the old structure only afterwards.
3Create an index without locking the table?
ALGORITHM=INPLACE, LOCK=NONE on MySQL, CREATE INDEX CONCURRENTLY on PostgreSQL.
4Why batch data migrations?
A large UPDATE blocks write access and increases replication lag. Batched updates with LIMIT avoid that.
5What to check in CI?
doctrine:migrations:status and doctrine:schema:validate after every migration run.
6Is rollback via down() always safe?
No, for destructive changes data is not restored by the rollback. A backup is the actual safety net.
7Organizing migrations across multiple teams?
Dedicated namespaces per bounded context via multiple migrations_paths entries.
8What does isTransactional() do?
When false, no implicit transaction is wrapped, necessary for online DDL statements.
9What is squashing?
Applied migrations are consolidated into one schema definition, keeping the file count manageable.
10NOT NULL without a default on a populated table?
Fails under strict mode. Set a default first, then enforce NOT NULL.