Doctrine Migrations in Symfony: Generate, Review and Deploy

Doctrine Migrations gives a Symfony application a versioned history of SQL changes, generated from the ORM mapping or written by hand. The tool is reliable; the mistakes happen around it: committing a diff without reading it, letting two namespaces fight over one table, or deploying with an interactive prompt that hangs the pipeline. This guide covers the pieces in the order you meet them: configuration, generating a migration, the class you get, reviewing it, running it in deployment, and what transactions do or do not protect you from.

Configuration: paths and namespaces#

The DoctrineMigrationsBundle reads config/packages/doctrine_migrations.yaml. The essential setting maps a PHP namespace to a directory:

doctrine_migrations:
    migrations_paths:
        'DoctrineMigrations': '%kernel.project_dir%/migrations'
    enable_profiler: false
    transactional: true

Every migration class in migrations/ must live in the DoctrineMigrations namespace, and every applied migration is recorded by its fully qualified class name. This is why renaming the namespace or moving files between directories is a breaking change: the recorded names stop matching the classes, and Doctrine believes nothing has run. If you must reorganise, do it in one commit and mark the old versions as migrated with doctrine:migrations:version --add --all on every database, not by hand-editing rows.

Several paths are allowed, which is useful in a modular monolith where each bundle owns its migrations. Keep the rule that one table is owned by one namespace; two modules generating diffs for the same table will each try to undo the other's changes.

diff versus generate#

There are two ways to get a new migration file:

bin/console doctrine:migrations:diff
bin/console doctrine:migrations:generate

diff compares the schema that your ORM mapping describes with the schema of the configured database and writes the statements that turn the latter into the former. generate writes an empty class for you to fill by hand. Teams that map everything with the ORM use diff for almost every change; teams that own parts of the schema outside the ORM (views, functions, tables of another service) use generate for those and diff for the rest.

Either way the result is a plain class and nothing about it is special once it exists. It is reviewed, committed and applied like any other code.

The Version class#

A generated file looks like this, here with hand-written SQL:

<?php

declare(strict_types=1);

namespace DoctrineMigrations;

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

final class Version20240101000001 extends AbstractMigration
{
    public function getDescription(): string
    {
        return 'create owners and pets';
    }

    public function up(Schema $schema): void
    {
        $this->addSql(<<<'SQL'
            CREATE TABLE owners
            (
                id         INT          NOT NULL,
                first_name VARCHAR(50)  NOT NULL,
                last_name  VARCHAR(50)  NOT NULL,
                telephone  VARCHAR(20),
                CONSTRAINT pk_owners PRIMARY KEY (id)
            )
            SQL);
        $this->addSql(<<<'SQL'
            CREATE TABLE pets
            (
                id       INT         NOT NULL,
                name     VARCHAR(50) NOT NULL,
                owner_id INT         NOT NULL,
                CONSTRAINT pk_pets PRIMARY KEY (id),
                CONSTRAINT fk_pets_owners FOREIGN KEY (owner_id) REFERENCES owners (id)
            )
            SQL);
    }

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

The pieces worth knowing:

  • The class name is the version. Version followed by a UTC timestamp, so files sort chronologically and two branches rarely collide. The timestamp is not a dependency declaration; migrations still run strictly in version order.
  • addSql() queues, it does not execute. Statements collected in up() run after the method returns, which is why you cannot read the result of one statement to decide the next. Use preUp() and postUp() when you need to query, or $this->connection directly for read-only checks.
  • The $schema parameter is the DBAL schema object and is mostly there for the schema-manipulation API; most teams ignore it and write SQL.
  • getDescription() shows up in doctrine:migrations:list and status. Fill it in; it is the cheapest documentation you will ever write.
  • Platform guards. A migration written for PostgreSQL can protect itself with abortIf() against the connection's platform, which stops someone from running it against a MySQL development database and getting a half-applied mess.

The doctrine_migration_versions table#

Applied migrations are recorded in doctrine_migration_versions, with the version name, the time it was executed and how long it took. The table name and columns are configurable under storage: table_storage in the bundle configuration, but there is rarely a reason to change them. The commands you will use around it:

bin/console doctrine:migrations:status
bin/console doctrine:migrations:list
bin/console doctrine:migrations:up-to-date
bin/console doctrine:migrations:version 'DoctrineMigrations\Version20240101000001' --add

up-to-date exits non-zero when migrations are pending, which makes it a good final step in a deployment or a check in CI that nobody forgot to commit a migration. version --add marks a migration as executed without running it, which is how you baseline a database that was created from a schema dump.

Because the table only records names, editing a migration that has already run changes nothing on the databases where it ran and everything on those where it has not. The rule is the same as with every migration tool: once merged and applied anywhere shared, the file is frozen.

Review the diff; it is not always right#

doctrine:migrations:diff is excellent and still needs a reader. The output is the difference between two schemas, and both sides can be wrong:

  • Anything in the database that is not in the mapping is scheduled for removal. A table created by another application, a view, or a helper table you added by hand will appear as DROP TABLE. Set schema_filter in the DBAL configuration to exclude such objects, or the next diff will try to delete them again.
  • The comparison runs against your local database. If it is behind or ahead of the migrations, the diff reflects that state, not the code. Run doctrine:migrations:migrate first, then diff.
  • Some changes are noise. Differences in column comments, default expressions or index names can produce statements that do nothing meaningful but still take a lock on a large table. Delete them from the migration.
  • Order and safety are your problem. The diff produces correct statements, not safe ones. Adding a NOT NULL column to a populated table or creating an index on a busy one needs the same care as in any other tool; see the expand and contract pattern.
  • Naming. Generated constraint and index names are hashes. They are stable, but unreadable in an error message. Mapping-level naming strategies or a manual rename in the migration fix that.

Run doctrine:schema:validate after applying a diff. If the mapping and the database agree, it reports so; if they do not, the diff missed something or you edited it into disagreement.

Deploying: migrate without prompts#

The command for a pipeline is:

bin/console doctrine:migrations:migrate --no-interaction --allow-no-migration

--no-interaction suppresses the confirmation prompt that would otherwise hang a non-TTY job. --allow-no-migration makes a run with nothing to do exit successfully instead of with an error, so a deployment that did not include a schema change still passes. Two useful variants: --dry-run prints what would happen, and --write-sql writes the statements to a file for a DBA to apply by hand, which is common in organisations where the application user has no DDL rights in production.

Run migrations as an explicit deployment step, once, before the new code takes traffic. Do not run them from a container entrypoint on every replica; two replicas starting at once will race, and the versions table is not a lock.

Transactions: what the platform gives you#

By default each migration runs inside a transaction (transactional: true, or isTransactional() on the class). What that buys you depends entirely on the engine. PostgreSQL and SQL Server support transactional DDL, so a failing statement rolls the whole migration back and the versions table is untouched. MySQL and MariaDB commit implicitly before and after most DDL statements, so a migration with three CREATE TABLE statements that fails on the third leaves two tables behind and no record that it ran. Oracle behaves like MySQL in this respect. The mechanism and its consequences are explained in Transactional DDL.

Two practical rules follow. On MySQL, keep migrations small enough that a partial failure is easy to reason about, and make the statements idempotent where the syntax allows. On PostgreSQL, turn the transaction off for the specific migrations that need it, such as CREATE INDEX CONCURRENTLY, by overriding isTransactional() to return false in that one class.

Where SchemaDrawer fits#

After a few dozen Version classes it is hard to say what schema they add up to without a database in front of you. SchemaDrawer takes the files from migrations/, runs them with Doctrine's migrator in Docker against a throw-away PostgreSQL, MySQL, MariaDB, SQL Server, Oracle or SQLite of the version you name, and returns an SVG diagram of the tables, columns, primary keys and foreign keys that exist afterwards. It is a fast way to check that a reviewed diff really produced the relationship you expected, and on Oracle it shows the upper-cased names the engine will report. The Doctrine page describes the accepted layout and namespace detection.

Key takeaways#

  • Namespace and path are part of a migration's identity; changing them means re-baselining every database.
  • Use diff for ORM-mapped changes and generate for everything else, and read every diff before committing it.
  • addSql() queues statements; use preUp() and postUp() when you need to look at data.
  • Deploy with migrate --no-interaction --allow-no-migration as a single explicit step.
  • Transactions protect you on PostgreSQL and SQL Server, not on MySQL, MariaDB or Oracle; size migrations accordingly.
Marek Hudyma
Builds SchemaDrawer, a service that draws database diagrams by running your migrations on a real engine. About
Keep reading

Related articles

Laravel Migrations: Best Practices for Teams

Practical rules for Laravel migrations in a team: one change per file, constrained foreign keys, frozen history, schema dumps, safe production runs and tests.

Draw your schema in the next five minutes

One HTTP call with your migration files, one poll, one SVG. Free while we build it.