Doctrine Migrations With Multiple Databases in Symfony: A Complete Setup Guide
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
--emflag 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_versionstable must live inside each tenant schema, not inpublic. 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_pathis 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 bothDATABASE_URLandANALYTICS_DATABASE_URLfor 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:migratewithout--configurationand assuming it migrated everything. It migrated the default database only. - Generating a diff with
--em=analyticsbut 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
diffgenerate every table for every database. - Letting the version table for all tenants live in
publicin a schema-per-tenant setup. - Forgetting
server_versionon 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.

