Doctrine Migrations With Multiple Databases in Symfony: A Complete Setup Guide

#doctrine migrations multiple databases
Sandor Farkas - Founder & Lead Developer at Wolf-Tech

Sandor Farkas

Founder & Lead Developer

Expert in software development and legacy code optimization

Running Doctrine migrations against multiple databases is one of those Symfony setups that has no single authoritative guide, even though plenty of real applications need it: a primary application database plus a separate analytics store, a legacy database that lives alongside a new one during a migration project, or a multi-tenant product with one schema per customer. If you have searched for how Doctrine migrations with multiple databases actually work, you have probably found fragments in the DoctrineMigrationsBundle docs, a few GitHub issues, and not much that connects them. This post is the complete setup, end to end.

How Doctrine Migrations Picks a Database (and Why It Ignores Your Second One)

The core thing to understand is that DoctrineMigrationsBundle is wired to exactly one EntityManager at a time. When you run bin/console doctrine:migrations:migrate, the bundle resolves the default EntityManager, takes its connection, and runs every registered migration against that connection. Your second database is not consulted, not versioned, and not migrated. There is no automatic fan-out.

That default is overridable in two ways:

  • The --em flag selects a different named EntityManager for a single command run: bin/console doctrine:migrations:migrate --em=analytics.
  • A separate migrations configuration file (passed via --configuration) defines an entirely independent migrations setup, with its own namespace, directory, and version tracking table.

The --em flag alone is not a complete solution, because by default all migration classes live in one namespace and one directory. If you run the same set of migration classes against two different databases, every migration written for database A will execute against database B the next time you migrate it. You need per-manager separation of the migration classes themselves. Let us build that properly.

Step 1: Configure Two Connections and Two EntityManagers

In config/packages/doctrine.yaml, define both connections and map each EntityManager to its own entity directory:

doctrine:
    dbal:
        default_connection: default
        connections:
            default:
                url: '%env(resolve:DATABASE_URL)%'
                server_version: '16'
            analytics:
                url: '%env(resolve:ANALYTICS_DATABASE_URL)%'
                server_version: '16'
    orm:
        default_entity_manager: default
        entity_managers:
            default:
                connection: default
                mappings:
                    App:
                        type: attribute
                        dir: '%kernel.project_dir%/src/Entity/Main'
                        prefix: 'App\Entity\Main'
            analytics:
                connection: analytics
                mappings:
                    Analytics:
                        type: attribute
                        dir: '%kernel.project_dir%/src/Entity/Analytics'
                        prefix: 'App\Entity\Analytics'

The entity directory split matters. If both EntityManagers map the same entities, doctrine:migrations:diff will generate the same schema for both databases, which is almost never what you want. One entity namespace per manager keeps the diffing clean.

Step 2: Give Each EntityManager Its Own Migrations Configuration

The bundle configuration in config/packages/doctrine_migrations.yaml covers the default manager:

doctrine_migrations:
    em: default
    migrations_paths:
        'DoctrineMigrations\Main': '%kernel.project_dir%/migrations/Main'
    storage:
        table_storage:
            table_name: 'doctrine_migration_versions'

For the second database, create a standalone configuration file the bundle does not load automatically, for example config/migrations/analytics.yaml:

em: analytics
migrations_paths:
    'DoctrineMigrations\Analytics': '%kernel.project_dir%/migrations/Analytics'
storage:
    table_storage:
        table_name: 'doctrine_migration_versions'

Two details are worth calling out:

  • Each configuration gets its own namespace and directory. A migration generated for the analytics database can never accidentally run against the main one, because the main configuration simply does not see it.
  • The version tracking table name can be identical in both files because each table lives in a different database. If both connections point at the same physical database with different schemas, give the tables distinct names instead.

Step 3: The Commands, Day to Day

Generating and running migrations now takes an explicit target. For the default database nothing changes:

bin/console doctrine:migrations:diff
bin/console doctrine:migrations:migrate -n

For the analytics database, point both the migrations config and the EntityManager at the second setup:

bin/console doctrine:migrations:diff \
  --configuration=config/migrations/analytics.yaml --em=analytics

bin/console doctrine:migrations:migrate -n \
  --configuration=config/migrations/analytics.yaml --em=analytics

Wrap these in Composer scripts or a Makefile early. In every team we have seen run this setup, the first production incident is someone running a bare doctrine:migrations:migrate and assuming it covered both databases. Make the correct invocation the easy one:

migrate:
	bin/console doctrine:migrations:migrate -n
	bin/console doctrine:migrations:migrate -n \
	  --configuration=config/migrations/analytics.yaml --em=analytics

Step 4: The CI and Deploy Pipeline Sequence

At deploy time, run the migrations for every database, sequentially, with failure stopping the pipeline:

deploy:
  script:
    - bin/console doctrine:migrations:migrate -n --allow-no-migration
    - bin/console doctrine:migrations:migrate -n --allow-no-migration
        --configuration=config/migrations/analytics.yaml --em=analytics
    - bin/console cache:clear

--allow-no-migration keeps the step green when one database has no pending migrations, which is the normal case. Order the commands by dependency: if analytics tables reference identifiers exported from the main database, migrate main first. And treat each command as independently retryable, because a network blip during the second migration should not require re-running the first.

In CI (as opposed to deploy), add a check that fails the build when an entity change has no corresponding migration:

bin/console doctrine:migrations:diff --dry-run || true
bin/console doctrine:schema:validate --skip-sync
bin/console doctrine:schema:validate --skip-sync --em=analytics

doctrine:schema:validate with --skip-sync validates the mapping files themselves; running it per EntityManager catches the classic mistake of adding an entity to the wrong namespace.

The Multi-Tenant Pattern: One Schema per Tenant, One Migration Set

The schema-per-tenant architecture is the more demanding variant of this problem: dozens or hundreds of PostgreSQL schemas, all structurally identical, all needing the same migration applied at deploy time. You do not want one migrations directory per tenant. You want one migration set executed once per tenant, with the schema selected dynamically.

The standard approach is a wrapper command that iterates tenants and switches the search_path before invoking the migrator:

foreach ($tenantRegistry->all() as $tenant) {
    $connection->executeStatement(
        sprintf('SET search_path TO %s', $tenant->schemaName())
    );
    $migrator->migrate();
    // version table lives inside each tenant schema,
    // so per-tenant state stays isolated
}

The important design decisions:

  • The doctrine_migration_versions table must live inside each tenant schema, not in public. Otherwise every tenant shares one version state and only the first tenant actually migrates.
  • Iterate tenants inside one process but reset the connection between tenants. Doctrine caches prepared statements per connection, and a stale search_path is a subtle way to migrate the wrong tenant.
  • Record per-tenant failures and continue, then report at the end. One tenant with a locked table should not leave ninety-nine others unmigrated.

If you are still deciding between schema-per-tenant, database-per-tenant, and row-level isolation, we compared the options in detail in our multi-tenant database architecture decision matrix.

Testing the Setup With PHPUnit

Multi-database migrations deserve their own test layer, because the failure mode is silent: everything works locally against database A while database B drifts. The pattern that works:

  • Maintain separate fixture sets per connection, and boot each test database by running the real migrations, not doctrine:schema:create. Migrations are code; untested code breaks.
  • In phpunit.dist.xml, define both DATABASE_URL and ANALYTICS_DATABASE_URL for the test environment, pointing at throwaway databases.
  • Add one smoke test that runs both migration sets from zero and asserts the resulting schema matches the mapping via doctrine:schema:validate. This single test catches the majority of multi-database drift bugs before deploy.

For live systems, the same zero-downtime rules apply per database that apply to one: expand-and-contract instead of destructive changes, and never couple a code deploy to a long-running migration. Our zero-downtime database migrations playbook covers that sequencing in depth.

Common Pitfalls, Collected

  • Running doctrine:migrations:migrate without --configuration and assuming it migrated everything. It migrated the default database only.
  • Generating a diff with --em=analytics but without --configuration, which writes the migration into the default namespace where the analytics runner never finds it.
  • Sharing one entity directory across both EntityManagers, which makes diff generate every table for every database.
  • Letting the version table for all tenants live in public in a schema-per-tenant setup.
  • Forgetting server_version on the second connection, which makes DBAL guess the platform and occasionally generate SQL for the wrong PostgreSQL version.

When to Get Help

A two-database setup is a well-trodden path once the configuration above is in place. Where projects get risky is the combination cases: a legacy database being strangled out while a new one grows, or a tenant fleet in the hundreds where a failed half-migration means inconsistent customer state. That is exactly the territory of our legacy code optimization work, and if you want a second pair of eyes on your migration architecture before it goes to production, our code quality consulting engagements regularly cover schema and migration reviews.

Questions about your specific setup? Write to hello@wolf-tech.io or find more of our engineering guides at wolf-tech.io.