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

Sandor Farkas
Founder & Lead Developer
Expert in software development and legacy code optimization
LinkedInShort answer: Doctrine Migrations handles one database per configuration. For a second database, give it its own EntityManager (or connection), its own migrations namespace and directory in a separate configuration file, and run every migrations command twice: once plain, once with --configuration plus --em (or --conn when there is no ORM mapping). Wrap both runs in one deploy script so nobody migrates only the default database.
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:
migrations_paths:
'DoctrineMigrations\Analytics': '../../migrations/Analytics'
table_storage:
table_name: 'doctrine_migration_versions'
This file uses a different format from doctrine_migrations.yaml. It is read by the Doctrine Migrations library itself, not by the Symfony container, which has two consequences that trip people up:
table_storagesits at the top level. Thestorage:wrapper from the bundle configuration does not exist here.- Symfony parameters such as
%kernel.project_dir%are not resolved. Use a path relative to the configuration file (as above) or an absolute path. If the path is wrong,difffails with an error about the migrations directory, which is easy to misread as a namespace problem.
Three more 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.
- The EntityManager is not set in this file. You pass it on the command line with
--em, which keeps the file reusable across environments.
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. Run both migration sets against empty test databases, then validate each EntityManager against its database:
bin/console doctrine:migrations:migrate -n --env=test
bin/console doctrine:migrations:migrate -n --env=test \
--configuration=config/migrations/analytics.yaml --em=analytics
bin/console doctrine:schema:validate --env=test
bin/console doctrine:schema:validate --env=test --em=analytics
Without --skip-sync, doctrine:schema:validate checks both the mapping and whether the database schema matches it. If someone changed an entity and forgot the migration, the schema after migrating is out of sync and the command fails. Running it per EntityManager also catches the classic mistake of adding an entity to the wrong namespace.
Checking Migration Status per Database
When something looks wrong in production, the first question is which database is behind. doctrine:migrations:status answers it, but again only for the configuration you point it at:
bin/console doctrine:migrations:status
bin/console doctrine:migrations:status \
--configuration=config/migrations/analytics.yaml --em=analytics
doctrine:migrations:list shows every migration with its state, and doctrine:migrations:up-to-date exits with a non-zero code when migrations are pending. The last one is useful as a readiness check after a deploy: run it for each database and fail the rollout if any of them is behind.
A Second Database Without Entities: Use --conn
Not every second database has entities. A common case is a reporting database or a legacy database that you only query with plain SQL through DBAL. There is no EntityManager to pass, so --em does not apply. Use --conn with the name of the DBAL connection instead:
bin/console doctrine:migrations:migrate -n \
--configuration=config/migrations/reporting.yaml --conn=reporting
Without an EntityManager, doctrine:migrations:diff has no mapping to compare against, so you write these migrations by hand with doctrine:migrations:generate. For a database that belongs to another system, that is usually what you want anyway: you do not want Doctrine proposing to drop tables it does not know about.
Mixing MySQL and PostgreSQL
The two databases do not have to use the same platform. A MySQL legacy database next to a PostgreSQL main database works with the same setup, as long as each connection declares its own server_version. Keep the migration classes strictly separated by configuration, because generated SQL is platform-specific: a migration generated against PostgreSQL will fail, or do something different, on MySQL. By default each migration checks the platform it runs on and aborts on a mismatch, so leave that check enabled.
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. - Copying the bundle's
storage:block or%kernel.project_dir%into the standalone configuration file, where neither works.
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.
