Doctrine Migrations mit mehreren Datenbanken in Symfony: Der komplette Setup-Guide

#doctrine migrations mehrere datenbanken
Sandor Farkas - Founder & Lead Developer at Wolf-Tech

Sandor Farkas

Gründer & Lead Developer

Experte für Softwareentwicklung und Legacy-Code-Optimierung

Doctrine Migrations gegen mehrere Datenbanken laufen zu lassen gehört zu den Symfony-Setups, für die es keinen einzelnen, verlässlichen Guide gibt, obwohl viele reale Anwendungen genau das brauchen: eine primäre Anwendungsdatenbank plus ein separater Analytics-Store, eine Legacy-Datenbank, die während eines Migrationsprojekts neben einer neuen existiert, oder ein Multi-Tenant-Produkt mit einem Schema pro Kunde. Wenn du gesucht hast, wie Doctrine Migrations mit mehreren Datenbanken tatsächlich funktionieren, hast du vermutlich Fragmente in der Doku des DoctrineMigrationsBundle gefunden, ein paar GitHub-Issues und wenig, das die Teile verbindet. Dieser Beitrag ist das komplette Setup, von Anfang bis Ende.

Wie Doctrine Migrations eine Datenbank auswählt (und warum es deine zweite ignoriert)

Der zentrale Punkt: Das DoctrineMigrationsBundle ist immer mit genau einem EntityManager verbunden. Wenn du bin/console doctrine:migrations:migrate ausführst, löst das Bundle den default EntityManager auf, nimmt dessen Connection und führt jede registrierte Migration gegen diese Connection aus. Deine zweite Datenbank wird nicht befragt, nicht versioniert und nicht migriert. Es gibt kein automatisches Fan-out.

Diesen Default kannst du auf zwei Wegen überschreiben:

  • Das Flag --em wählt für einen einzelnen Befehl einen anderen benannten EntityManager: bin/console doctrine:migrations:migrate --em=analytics.
  • Eine separate Migrations-Konfigurationsdatei (übergeben via --configuration) definiert ein komplett unabhängiges Migrations-Setup mit eigenem Namespace, eigenem Verzeichnis und eigener Versionstabelle.

Das Flag --em allein ist keine vollständige Lösung, weil standardmäßig alle Migrationsklassen in einem Namespace und einem Verzeichnis liegen. Führst du denselben Satz Migrationsklassen gegen zwei verschiedene Datenbanken aus, läuft jede für Datenbank A geschriebene Migration beim nächsten Migrieren auch gegen Datenbank B. Du brauchst eine Trennung der Migrationsklassen pro Manager. Bauen wir das sauber auf.

Schritt 1: Zwei Connections und zwei EntityManager konfigurieren

Definiere in config/packages/doctrine.yaml beide Connections und ordne jedem EntityManager sein eigenes Entity-Verzeichnis zu:

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'

Die Aufteilung der Entity-Verzeichnisse ist wichtig. Wenn beide EntityManager dieselben Entities mappen, generiert doctrine:migrations:diff dasselbe Schema für beide Datenbanken, was fast nie das ist, was du willst. Ein Entity-Namespace pro Manager hält das Diffing sauber.

Schritt 2: Jedem EntityManager seine eigene Migrations-Konfiguration geben

Die Bundle-Konfiguration in config/packages/doctrine_migrations.yaml deckt den Default-Manager ab:

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

Für die zweite Datenbank legst du eine eigenständige Konfigurationsdatei an, die das Bundle nicht automatisch lädt, zum Beispiel config/migrations/analytics.yaml:

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

Zwei Details sind hier erwähnenswert:

  • Jede Konfiguration bekommt ihren eigenen Namespace und ihr eigenes Verzeichnis. Eine für die Analytics-Datenbank generierte Migration kann niemals versehentlich gegen die Hauptdatenbank laufen, weil die Haupt-Konfiguration sie schlicht nicht sieht.
  • Der Name der Versionstabelle kann in beiden Dateien identisch sein, weil jede Tabelle in einer anderen Datenbank liegt. Zeigen beide Connections auf dieselbe physische Datenbank mit unterschiedlichen Schemas, gib den Tabellen stattdessen unterschiedliche Namen.

Schritt 3: Die Befehle im Alltag

Migrationen generieren und ausführen braucht jetzt ein explizites Ziel. Für die Default-Datenbank ändert sich nichts:

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

Für die Analytics-Datenbank zeigst du sowohl die Migrations-Konfiguration als auch den EntityManager auf das zweite 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

Packe diese Befehle früh in Composer-Scripts oder ein Makefile. In jedem Team, das wir mit diesem Setup gesehen haben, war der erste Produktionsvorfall jemand, der ein nacktes doctrine:migrations:migrate ausgeführt und angenommen hat, es decke beide Datenbanken ab. Mach den korrekten Aufruf zum einfachen:

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

Schritt 4: Die Befehlsfolge in CI und Deploy-Pipeline

Führe beim Deploy die Migrationen für jede Datenbank sequenziell aus, wobei ein Fehler die Pipeline stoppt:

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 hält den Schritt grün, wenn eine Datenbank keine ausstehenden Migrationen hat, was der Normalfall ist. Ordne die Befehle nach Abhängigkeit: Wenn Analytics-Tabellen Identifier referenzieren, die aus der Hauptdatenbank exportiert werden, migriere zuerst die Hauptdatenbank. Und behandle jeden Befehl als unabhängig wiederholbar, denn ein Netzwerk-Aussetzer während der zweiten Migration sollte nicht erfordern, die erste erneut auszuführen.

In der CI (im Gegensatz zum Deploy) ergänzt du einen Check, der den Build fehlschlagen lässt, wenn eine Entity-Änderung keine zugehörige Migration hat:

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 mit --skip-sync validiert die Mapping-Dateien selbst; es pro EntityManager auszuführen fängt den klassischen Fehler ab, eine Entity im falschen Namespace anzulegen.

Das Multi-Tenant-Muster: Ein Schema pro Tenant, ein Migrationssatz

Die Schema-pro-Tenant-Architektur ist die anspruchsvollere Variante dieses Problems: Dutzende oder Hunderte PostgreSQL-Schemas, alle strukturell identisch, alle brauchen beim Deploy dieselbe Migration. Du willst nicht ein Migrationsverzeichnis pro Tenant. Du willst einen Migrationssatz, der einmal pro Tenant ausgeführt wird, mit dynamisch gewähltem Schema.

Der Standardansatz ist ein Wrapper-Command, der über die Tenants iteriert und vor dem Aufruf des Migrators den search_path umschaltet:

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
}

Die wichtigen Design-Entscheidungen:

  • Die Tabelle doctrine_migration_versions muss innerhalb jedes Tenant-Schemas liegen, nicht in public. Sonst teilen sich alle Tenants einen Versionsstand und nur der erste Tenant wird tatsächlich migriert.
  • Iteriere über die Tenants innerhalb eines Prozesses, aber setze die Connection zwischen den Tenants zurück. Doctrine cached Prepared Statements pro Connection, und ein veralteter search_path ist ein subtiler Weg, den falschen Tenant zu migrieren.
  • Protokolliere Fehler pro Tenant und mach weiter, berichte am Ende. Ein Tenant mit einer gesperrten Tabelle sollte nicht neunundneunzig andere unmigriert zurücklassen.

Wenn du noch zwischen Schema-pro-Tenant, Datenbank-pro-Tenant und Row-Level-Isolation abwägst, haben wir die Optionen in unserer Entscheidungsmatrix zur Multi-Tenant-Datenbankarchitektur im Detail verglichen.

Das Setup mit PHPUnit testen

Multi-Datenbank-Migrationen verdienen ihre eigene Testebene, denn der Fehlermodus ist still: Lokal funktioniert alles gegen Datenbank A, während Datenbank B driftet. Das Muster, das funktioniert:

  • Pflege separate Fixture-Sets pro Connection und initialisiere jede Testdatenbank durch die echten Migrationen, nicht durch doctrine:schema:create. Migrationen sind Code; ungetesteter Code geht kaputt.
  • Definiere in der phpunit.dist.xml sowohl DATABASE_URL als auch ANALYTICS_DATABASE_URL für die Testumgebung, zeigend auf Wegwerf-Datenbanken.
  • Ergänze einen Smoke-Test, der beide Migrationssätze von null ausführt und via doctrine:schema:validate prüft, dass das resultierende Schema zum Mapping passt. Dieser eine Test fängt die Mehrheit der Multi-Datenbank-Drift-Bugs vor dem Deploy.

Für Live-Systeme gelten pro Datenbank dieselben Zero-Downtime-Regeln wie bei einer: Expand-and-Contract statt destruktiver Änderungen, und koppel nie ein Code-Deploy an eine lang laufende Migration. Unser Playbook für Zero-Downtime-Datenbankmigrationen behandelt diese Sequenzierung ausführlich.

Häufige Stolperfallen, gesammelt

  • doctrine:migrations:migrate ohne --configuration ausführen und annehmen, es habe alles migriert. Es hat nur die Default-Datenbank migriert.
  • Ein Diff mit --em=analytics, aber ohne --configuration generieren, wodurch die Migration im Default-Namespace landet, wo der Analytics-Runner sie nie findet.
  • Ein Entity-Verzeichnis über beide EntityManager teilen, wodurch diff jede Tabelle für jede Datenbank generiert.
  • In einem Schema-pro-Tenant-Setup die Versionstabelle aller Tenants in public liegen lassen.
  • server_version bei der zweiten Connection vergessen, wodurch DBAL die Plattform rät und gelegentlich SQL für die falsche PostgreSQL-Version generiert.

Wann du dir Hilfe holen solltest

Ein Setup mit zwei Datenbanken ist ein gut ausgetretener Pfad, sobald die Konfiguration oben steht. Riskant werden Projekte in den Kombinationsfällen: eine Legacy-Datenbank, die per Strangler-Ansatz abgelöst wird, während eine neue wächst, oder eine Tenant-Flotte im dreistelligen Bereich, wo eine halb fehlgeschlagene Migration inkonsistenten Kundenstand bedeutet. Das ist genau das Terrain unserer Arbeit in der Legacy-Code-Optimierung, und wenn du ein zweites Paar Augen auf deine Migrationsarchitektur willst, bevor sie in Produktion geht, decken unsere Engagements im Code Quality Consulting regelmäßig Schema- und Migrations-Reviews ab.

Fragen zu deinem konkreten Setup? Schreib an hello@wolf-tech.io oder finde weitere unserer Engineering-Guides auf wolf-tech.io.