Doctrine Migrations Best Practices
AI generated
SF
{ }
Symfony · Doctrine Migrations · Datenbank · DevOps
Doctrine Migrations Best Practices
sichere Schema-Änderungen ohne Downtime

Ein blind ausgeführtes Migrations-Diff kann Datenverlust, Tabellensperren oder Downtime verursachen. Mit dem Expand-Contract-Pattern, additive Migrations und konsequenter CI-Prüfung werden Doctrine Migrations planbar, symmetrisch und produktionssicher.

19 Min. Lesezeit Migrations · Expand-Contract · Zero-Downtime · CI Symfony 7 · Doctrine Migrations 3 · MySQL 8

1. Warum Migrations-Diffs nicht blind vertrauen

Der Befehl doctrine:migrations:diff generiert automatisch eine Migration aus dem Unterschied zwischen aktuellem Entity-Mapping und tatsächlichem Datenbankschema. Diese Automatisierung ist der Grund, warum Doctrine Migrations so beliebt sind, birgt aber ein systematisches Risiko: Der Diff-Generator kennt keine fachliche Absicht, nur strukturelle Unterschiede. Eine umbenannte Spalte wird vom Generator fast immer als DROP COLUMN gefolgt von ADD COLUMN interpretiert, nicht als RENAME COLUMN. Das Ergebnis: sämtliche Daten in dieser Spalte gehen verloren, obwohl die Absicht lediglich eine Umbenennung war.

Der erste und wichtigste Schritt bei jeder generierten Migration ist deshalb, sie mit --dry-run auszuführen und den generierten SQL-Code Zeile für Zeile zu lesen, bevor sie auf eine echte Datenbank angewendet wird. Diese Disziplin ist bei Doctrine Migrations nicht optional, sie ist die einzige verlässliche Absicherung gegen automatisch generierte, aber fachlich falsche Schema-Änderungen. Wer Migrations blind aus dem Diff übernimmt und direkt in Produktion ausführt, riskiert genau die Art von Datenverlust, die Migrations eigentlich verhindern sollen.

Ein zweiter Aspekt betrifft implizite Verhaltensänderungen: Das Hinzufügen einer NOT NULL-Spalte ohne Default-Wert auf eine bereits befüllte Tabelle schlägt in MySQL mit strict mode sofort fehl, in älteren Konfigurationen füllt es die Spalte stillschweigend mit einem impliziten Default. Beide Verhaltensweisen sind bei Doctrine Migrations zu prüfen, bevor die Migration in einer Umgebung mit echten Produktionsdaten läuft, nicht erst danach.

2. Sichere Migrationen: up und down symmetrisch schreiben

Jede Migration in Doctrine Migrations besteht aus einer up()- und einer down()-Methode. Die down()-Methode soll den exakten Zustand vor der Migration wiederherstellen, aber in der Praxis wird sie oft vernachlässigt oder mit einem leeren Kommentar versehen. Das rächt sich, sobald eine Migration in Produktion fehlerhaft ist und zurückgerollt werden muss, während parallel bereits Anwendungscode auf das neue Schema deployt wurde.


<?php

declare(strict_types=1);

namespace DoctrineMigrations;

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

final class Version20260730120000 extends AbstractMigration
{
    public function getDescription(): string
    {
        return 'Add nullable "sku" column to product table';
    }

    public function up(Schema $schema): void
    {
        $this->addSql('ALTER TABLE product ADD sku VARCHAR(64) DEFAULT NULL');
        $this->addSql('CREATE INDEX idx_product_sku ON product (sku)');
    }

    public function down(Schema $schema): void
    {
        $this->addSql('DROP INDEX idx_product_sku ON product');
        $this->addSql('ALTER TABLE product DROP sku');
    }
}

Eine wichtige Regel bei Doctrine Migrations: Jede Migration sollte in genau einer Transaktion laufen können, sofern das Datenbanksystem transaktionale DDL unterstützt. PostgreSQL erlaubt das vollständig, MySQL nur eingeschränkt, da bestimmte DDL-Befehle implizite Commits auslösen. Für MySQL bedeutet das, größere Migrationen in mehrere kleinere, unabhängig anwendbare Schritte aufzuteilen, statt auf eine Transaktion zu vertrauen, die es in dieser Form gar nicht gibt.

3. Zero-Downtime mit dem Expand-Contract-Pattern

Das Expand-Contract-Pattern ist die zentrale Technik, um Doctrine Migrations ohne Downtime auf Anwendungen mit mehreren gleichzeitig laufenden Deployments anzuwenden. Statt eine Spalte in einem einzigen Schritt umzubenennen oder ihren Typ zu ändern, wird die Änderung in drei unabhängige Deployments aufgeteilt: Expand fügt die neue Struktur additiv hinzu, ohne die alte zu entfernen. Migrate schreibt Anwendungscode, der beide Strukturen parallel befüllt oder liest. Contract entfernt die alte Struktur erst, nachdem sämtlicher alter Code entfernt wurde.

Diese Aufteilung ist notwendig, weil bei rollierenden Deployments für kurze Zeit alte und neue Anwendungsversionen parallel auf dieselbe Datenbank zugreifen. Eine Doctrine Migration, die eine Spalte in einem Schritt umbenennt, bricht sofort den alten Code, der die ursprüngliche Spalte noch referenziert. Mit Expand-Contract bleibt das Schema während der gesamten Rollout-Phase kompatibel mit beiden Codeversionen.


<?php

declare(strict_types=1);

namespace DoctrineMigrations;

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

// EXPAND migration: add new column alongside the old one, additive only
final class Version20260730130000 extends AbstractMigration
{
    public function getDescription(): string
    {
        return 'Expand: add new "price_cents" column, keep legacy "price" column';
    }

    public function up(Schema $schema): void
    {
        $this->addSql('ALTER TABLE product ADD price_cents INT DEFAULT NULL');
        // Backfill in small batches, not in a single UPDATE — see section 5
        $this->addSql(
            'UPDATE product SET price_cents = ROUND(price * 100) WHERE price_cents IS NULL LIMIT 5000'
        );
    }

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

Erst in einer späteren, separaten Migration, nachdem der gesamte Code auf price_cents umgestellt und deployt wurde, folgt der Contract-Schritt mit DROP COLUMN price. Diese zeitliche Trennung ist der Kern jeder Zero-Downtime-Strategie mit Doctrine Migrations.

4. Indizes auf großen Tabellen ohne Tabellensperre

Auf Tabellen mit mehreren Millionen Zeilen kann eine naive CREATE INDEX-Anweisung die Tabelle für die Dauer der Index-Erstellung sperren, was in Produktion zu Timeouts und Request-Fehlern führt. MySQL mit InnoDB unterstützt seit Version 5.6 Online-DDL für viele Index-Operationen, gesteuert über ALGORITHM=INPLACE und LOCK=NONE. Eine Doctrine Migration, die diese Hinweise explizit setzt, vermeidet die längere exklusive Sperre der klassischen Tabellenkopie.


<?php

declare(strict_types=1);

namespace DoctrineMigrations;

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

final class Version20260730140000 extends AbstractMigration
{
    public function getDescription(): string
    {
        return 'Add index on large orders table without locking it';
    }

    public function up(Schema $schema): void
    {
        // ALGORITHM=INPLACE avoids a full table copy, LOCK=NONE keeps writes flowing
        $this->addSql(
            'ALTER TABLE orders ADD INDEX idx_orders_customer_id (customer_id), ALGORITHM=INPLACE, LOCK=NONE'
        );
    }

    public function down(Schema $schema): void
    {
        $this->addSql('ALTER TABLE orders DROP INDEX idx_orders_customer_id, ALGORITHM=INPLACE, LOCK=NONE');
    }

    public function isTransactional(): bool
    {
        // Online DDL statements are not compatible with an implicit transaction wrapper
        return false;
    }
}

Wichtig für Doctrine Migrations auf MySQL: Die Methode isTransactional() muss false zurückgeben, wenn die Migration Online-DDL-Anweisungen enthält, da diese sonst mit dem impliziten Transaktions-Wrapper des Migrations-Bundles kollidieren können. PostgreSQL bietet mit CREATE INDEX CONCURRENTLY ein vergleichbares Konzept, das ebenfalls außerhalb einer Transaktion laufen muss.

5. Daten-Migrationen von Schema-Änderungen trennen

Eine große Datenmigration, die Millionen Zeilen in einem einzigen UPDATE-Statement verändert, blockiert Schreibzugriffe auf die betroffene Tabelle für die gesamte Laufzeit der Anweisung und kann den Replikations-Lag auf Read-Replicas dramatisch erhöhen. Doctrine Migrations sollten Datenänderungen deshalb in kleinen, wiederholbaren Batches ausführen, mit LIMIT-Klauseln und einer Schleife, die den Fortschritt zwischen den Batches kurz pausiert, um Replikations-Lag abzubauen.

Für sehr große Datenmengen, bei denen selbst eine gebatchte Migration zu lange für ein einzelnes Deployment-Fenster dauert, ist die richtige Antwort, die Datenmigration komplett aus der Schema-Migration herauszulösen und als separaten, asynchronen Prozess über die Messenger-Komponente zu implementieren. Die Doctrine Migration selbst beschränkt sich dann auf die additive Schema-Änderung, während ein eigenständiges Kommando oder ein Messenger-Handler die eigentliche Datenmigration im Hintergrund abarbeitet, mit Fortschrittsanzeige und Fehlerbehandlung.

6. Versionierung und Organisation der Migrationsdateien

Mit wachsendem Projekt sammeln sich schnell hunderte Migrationsdateien an, was doctrine:migrations:migrate spürbar verlangsamt, weil jede einzelne Migration beim Start geprüft wird. Doctrine Migrations unterstützt das sogenannte Squashing: Alle bereits in Produktion angewendeten Migrationen werden zu einer einzigen konsolidierten Schema-Definition zusammengefasst, während die Versions-Tabelle in der Datenbank unverändert bleibt. Neue Migrationen bauen danach auf dieser konsolidierten Basis auf.

Für Teams mit mehreren parallelen Feature-Branches empfiehlt sich zusätzlich ein Namespace pro Bounded Context, etwa DoctrineMigrations\Billing und DoctrineMigrations\Catalog, konfiguriert über mehrere migrations_paths-Einträge in der Bundle-Konfiguration. Das reduziert Merge-Konflikte zwischen Teams erheblich, weil unterschiedliche Teams in unterschiedlichen Verzeichnissen arbeiten, auch wenn beide gleichzeitig neue Doctrine Migrations erstellen.

7. CI-Integration und automatisierte Prüfungen

Der Befehl doctrine:migrations:status zeigt an, ob alle Migrationen angewendet wurden und ob das aktuelle Schema mit den vorhandenen Mapping-Definitionen übereinstimmt. In einer CI-Pipeline lohnt es sich, diesen Befehl nach jedem Migrations-Lauf zu prüfen und den Build fehlschlagen zu lassen, wenn eine Diskrepanz gefunden wird. Ergänzend prüft doctrine:schema:validate, ob das Entity-Mapping und das tatsächliche Datenbankschema nach Anwendung aller Doctrine Migrations konsistent sind, was vergessene oder fehlerhafte Migrationsdateien zuverlässig aufdeckt.


# .gitlab-ci.yml — verify migrations before deploy
migration-check:
  stage: test
  script:
    - bin/console doctrine:database:create --if-not-exists --env=test
    - bin/console doctrine:migrations:migrate --no-interaction --env=test
    - bin/console doctrine:schema:validate --env=test
    - bin/console doctrine:migrations:status --env=test | grep -q "New Migrations: *0" || exit 1

Diese Prüfungen laufen bei jedem Merge Request und stellen sicher, dass niemand versehentlich Entity-Mapping-Änderungen ohne zugehörige Doctrine Migration committet, ein Fehler, der sonst erst beim nächsten Deployment auf einer echten Umgebung sichtbar würde.

8. Rollback-Strategien richtig einsetzen

Ein Rollback über doctrine:migrations:migrate prev ist bei Doctrine Migrations nur dann sicher, wenn die Migration wirklich reversibel ist, ohne Datenverlust zu erzeugen. Eine Migration, die eine Spalte löscht, kann in down() die Spalte zwar wieder anlegen, aber die zuvor enthaltenen Daten sind unwiderruflich verloren. Für solche destruktiven Änderungen ist ein Rollback im klassischen Sinne eine Illusion, kein echter Sicherheitsnetz.

In der Praxis ist die verlässlichere Strategie bei Doctrine Migrations, destruktive Schema-Änderungen erst nach einer Wartephase von mehreren Tagen oder Wochen durchzuführen, nachdem verifiziert wurde, dass die alte Struktur wirklich nicht mehr benötigt wird, und in der Zwischenzeit ein aktuelles Datenbank-Backup als eigentliches Rollback-Mittel zu betrachten. Ein Rollback per down()-Methode eignet sich gut für additive, nicht destruktive Änderungen, aber nicht als universelle Absicherung gegen jede Art von Migrationsfehler.

9. Migrations-Patterns im direkten Vergleich

Die folgende Tabelle stellt unterschiedliche Ansätze für Schema-Änderungen mit Doctrine Migrations gegenüber und zeigt, welches Risiko jeweils besteht und welches Pattern es entschärft.

Änderungstyp Risikoreicher Ansatz Empfohlenes Pattern Vorteil
Spalte umbenennen DROP + ADD (Diff-Default) Expand-Contract mit paralleler Spalte Kein Datenverlust, kein Breaking Change
Index auf großer Tabelle CREATE INDEX (Default-Lock) ALGORITHM=INPLACE, LOCK=NONE Keine exklusive Tabellensperre
Millionen Zeilen aktualisieren Ein einzelnes UPDATE-Statement Gebatchte Updates oder Messenger-Job Kein Replikations-Lag, unterbrechbar
NOT NULL ohne Default Direkt auf befüllter Tabelle Erst Default setzen, dann NOT NULL Kein Fehlschlag bei strict mode
Migration validieren Direkt in Produktion testen --dry-run plus CI-Pipeline-Check Fehler vor dem Deployment sichtbar

Die Tabelle zeigt ein durchgängiges Muster: Fast jedes Risiko bei Doctrine Migrations entsteht aus einer einzigen, großen, synchronen Operation. Die Lösung ist fast immer dieselbe, die Änderung additiv, gebatcht und zeitlich entkoppelt vom Anwendungscode durchzuführen, statt sie in einem einzigen destruktiven Schritt zu erzwingen.

Mironsoft

Symfony-Deployment, Datenbank-Migrationen und Zero-Downtime-Architektur

Schema-Änderungen ohne Downtime deployen?

Wir richten Expand-Contract-Workflows, CI-Migrations-Checks und Batch-Prozesse für eure Datenmigrationen ein, damit Deployments planbar bleiben, auch bei Tabellen mit Millionen Zeilen.

Migrations-Audit

Bestehende Migrationen auf Risiken und fehlende down()-Methoden prüfen

Zero-Downtime-Setup

Expand-Contract-Workflow für rollierende Deployments etablieren

CI-Integration

Automatisierte Migrations-Checks in der Deployment-Pipeline

10. Zusammenfassung

Sichere Doctrine Migrations entstehen nicht durch blindes Vertrauen in den Diff-Generator, sondern durch systematische Disziplin: jede generierte Migration mit --dry-run prüfen, up() und down() symmetrisch halten, und destruktive Änderungen konsequent über das Expand-Contract-Pattern in mehrere, zeitlich entkoppelte Deployments aufteilen. Auf großen Tabellen verhindert ALGORITHM=INPLACE, LOCK=NONE unnötige Sperren, und Datenmigrationen gehören in gebatchte Schleifen oder asynchrone Messenger-Jobs, niemals in ein einzelnes großes UPDATE-Statement.

CI-Checks mit doctrine:migrations:status und doctrine:schema:validate fangen vergessene Migrationen ab, bevor sie in Produktion zum Problem werden. Rollback über down() ist ein nützliches Werkzeug für additive Änderungen, aber kein Ersatz für ein aktuelles Datenbank-Backup bei destruktiven Operationen. Wer diese Prinzipien konsequent auf jede Doctrine Migration anwendet, reduziert das Risiko von Downtime und Datenverlust erheblich.

Doctrine Migrations Best Practices — Das Wichtigste auf einen Blick

Diff pruefen

Jede generierte Migration mit --dry-run lesen, bevor sie angewendet wird. Umbenennungen werden sonst zu DROP plus ADD.

Expand-Contract

Destruktive Änderungen in Expand, Migrate und Contract aufteilen, für Kompatibilität während rollierender Deployments.

Große Tabellen

ALGORITHM=INPLACE, LOCK=NONE für Indizes, gebatchte UPDATE-Statements für Datenmigrationen.

CI-Absicherung

doctrine:migrations:status und doctrine:schema:validate in jeder Pipeline erzwingen.

11. FAQ: Doctrine Migrations Best Practices

1Warum sind generierte Migrations riskant?
Der Diff-Generator interpretiert Umbenennungen als DROP plus ADD, was Datenverlust verursacht. --dry-run ist deshalb Pflicht.
2Was ist Expand-Contract?
Drei Schritte: Expand fuegt additiv hinzu, Migrate stellt Code um, Contract entfernt Altes erst danach.
3Index ohne Tabellensperre erstellen?
ALGORITHM=INPLACE, LOCK=NONE bei MySQL, CREATE INDEX CONCURRENTLY bei PostgreSQL.
4Warum Datenmigrationen batchen?
Ein grosses UPDATE blockiert Schreibzugriffe und erhoeht Replikations-Lag. Gebatchte Updates mit LIMIT vermeiden das.
5Was in CI pruefen?
doctrine:migrations:status und doctrine:schema:validate nach jedem Migrations-Lauf.
6Ist Rollback ueber down() immer sicher?
Nein, bei destruktiven Aenderungen sind Daten nach dem Rollback nicht wiederhergestellt. Backup ist das eigentliche Sicherheitsnetz.
7Migrationen bei mehreren Teams organisieren?
Eigene Namespaces pro Bounded Context ueber mehrere migrations_paths-Eintraege.
8Was macht isTransactional()?
Bei false wird keine implizite Transaktion gewrapped, notwendig fuer Online-DDL-Statements.
9Was ist Squashing?
Angewendete Migrationen werden zu einer konsolidierten Schema-Definition zusammengefasst, um die Dateianzahl handhabbar zu halten.
10NOT NULL ohne Default auf befuellter Tabelle?
Schlaegt in strict mode fehl. Erst Default setzen, dann NOT NULL erzwingen.