Datenbank Migrationen ohne Framework in PHP selbst bauen
AI generated
<?php
8.4
PHP · Datenbank · Deployment
Datenbank Migrationen ohne Framework
Ein eigenes Versionierungssystem in reinem PHP

Datenbank Migrationen sind versionierte, nachvollziehbare Schema-Änderungen, die jedes Team ohne manuelles ALTER TABLE über SSH-Verbindungen ausführt. Ein selbstgebautes Migrationssystem aus Migrationstabelle, Up- und Down-Methoden sowie einem schlanken CLI-Runner deckt die Kernanforderungen ab, ohne Doctrine Migrations oder Laravel als Abhängigkeit einzuführen.

17 Min. Lesezeit Migrationstabelle · Up/Down · Rollback PHP 8.4 · PDO · CI/CD-tauglich

1. Warum Datenbank Migrationen überhaupt notwendig sind

Datenbank Migrationen lösen ein Problem, das jedes wachsende Projekt irgendwann trifft: Das Datenbankschema muss sich zusammen mit dem Code ändern, aber ohne dass jemand manuell ALTER TABLE-Befehle auf der Produktionsdatenbank eintippt. Ohne Datenbank Migrationen entsteht schnell eine Situation, in der niemand mehr genau weiß, welche Spalte auf welcher Umgebung existiert und welche nicht. Ein Kollege fügt lokal eine Spalte hinzu, vergisst sie zu dokumentieren, und drei Wochen später bricht das Deployment auf Staging, weil dort dieselbe Spalte fehlt.

Datenbank Migrationen lösen dieses Problem durch Versionierung: Jede Schema-Änderung wird als eigene, nummerierte Datei im Code abgelegt, mit Versionskontrolle wie Git verfolgt und über ein Skript in exakt derselben Reihenfolge auf jeder Umgebung ausgeführt. Das Ergebnis ist ein Schema, dessen aktueller Stand jederzeit aus dem Code heraus reproduzierbar ist, unabhängig davon, ob die Datenbank lokal, auf Staging oder in Produktion läuft.

Bekannte Frameworks wie Symfony oder Laravel bringen fertige Migrationssysteme mit, aber das zugrunde liegende Prinzip ist einfach genug, um es auch ohne Framework zu verstehen und selbst zu bauen. Für kleinere Projekte oder reine PHP-Anwendungen ohne volles Framework ist ein eigenes, schlankes Migrationssystem oft die bessere Wahl als eine schwergewichtige Abhängigkeit nur für Schema-Verwaltung.

2. Die Migrationstabelle: Grundstein jedes Migrationssystems

Jedes Migrationssystem braucht eine eigene Tabelle in der Datenbank, die protokolliert, welche Datenbank Migrationen bereits ausgeführt wurden. Diese Tabelle, meist migrations genannt, speichert mindestens den Dateinamen oder eine Versionsnummer sowie einen Zeitstempel der Ausführung. Beim Start des Migrations-Runners wird zuerst geprüft, ob diese Tabelle existiert, und falls nicht, wird sie automatisch angelegt, damit das System auf einer komplett leeren Datenbank funktioniert.

Die Migrationstabelle ist der Zustandsspeicher, der entscheidet, welche Datenbank Migrationen beim nächsten Lauf noch ausgeführt werden müssen. Ohne diese Tabelle müsste bei jedem Deployment manuell nachvollzogen werden, welcher Schema-Stand bereits erreicht wurde, was in Teams mit mehreren Entwicklern schnell zu Inkonsistenzen führt.


<?php

declare(strict_types=1);

final class MigrationRepository
{
    public function __construct(private readonly PDO $pdo)
    {
    }

    /** Creates the tracking table if it does not exist yet. */
    public function ensureTableExists(): void
    {
        $this->pdo->exec(<<<SQL
            CREATE TABLE IF NOT EXISTS migrations (
                id INT AUTO_INCREMENT PRIMARY KEY,
                migration VARCHAR(255) NOT NULL UNIQUE,
                executed_at DATETIME NOT NULL
            )
        SQL);
    }

    /** @return array<int, string> Names of already executed migrations. */
    public function getExecuted(): array
    {
        $statement = $this->pdo->query('SELECT migration FROM migrations ORDER BY id');
        return $statement->fetchAll(PDO::FETCH_COLUMN);
    }

    public function markExecuted(string $migration): void
    {
        $statement = $this->pdo->prepare(
            'INSERT INTO migrations (migration, executed_at) VALUES (?, NOW())'
        );
        $statement->execute([$migration]);
    }

    public function markRolledBack(string $migration): void
    {
        $statement = $this->pdo->prepare('DELETE FROM migrations WHERE migration = ?');
        $statement->execute([$migration]);
    }
}

3. Eine Migrationsklasse mit up() und down() entwerfen

Jede einzelne Schema-Änderung wird als eigene Klasse mit zwei Methoden abgebildet: up() führt die Änderung aus, down() macht sie rückgängig. Diese Symmetrie ist der Kern jedes Migrationssystems, weil sie es erlaubt, ein Schema nicht nur vorwärts, sondern auch rückwärts zu einem früheren Zustand zu bewegen. Der Dateiname jeder Datenbank Migration beginnt üblicherweise mit einem Zeitstempel, damit die alphabetische Sortierung automatisch der chronologischen Reihenfolge entspricht.

Ein Interface für alle Migrationsklassen erzwingt diese Struktur und ermöglicht dem Runner, jede Migration unabhängig von ihrem konkreten Inhalt gleich zu behandeln. Der eigentliche SQL-Code innerhalb von up() und down() bleibt bewusst einfach: reines, gut lesbares SQL statt eines abstrahierten Schema-Builders, was Debugging bei Problemen erheblich erleichtert.


<?php

declare(strict_types=1);

interface Migration
{
    public function up(PDO $pdo): void;
    public function down(PDO $pdo): void;
}

/** Migration: 20260731120000_create_orders_table.php */
final class CreateOrdersTable implements Migration
{
    public function up(PDO $pdo): void
    {
        $pdo->exec(<<<SQL
            CREATE TABLE orders (
                id INT AUTO_INCREMENT PRIMARY KEY,
                customer_id INT NOT NULL,
                total DECIMAL(10,2) NOT NULL,
                status VARCHAR(20) NOT NULL DEFAULT 'pending',
                created_at DATETIME NOT NULL,
                INDEX idx_customer (customer_id)
            )
        SQL);
    }

    public function down(PDO $pdo): void
    {
        $pdo->exec('DROP TABLE IF EXISTS orders');
    }
}

4. Der Migrations-Runner: Reihenfolge und Ausführung

Der Migrations-Runner ist das Bindeglied zwischen den Migrationsdateien im Dateisystem und der Migrationstabelle in der Datenbank. Beim Ausführen liest er alle verfügbaren Migrationsklassen aus einem Verzeichnis, vergleicht sie mit den bereits ausgeführten Einträgen aus der Migrationstabelle und führt nur die Differenz aus, in aufsteigender Reihenfolge nach Zeitstempel. Diese Differenzbildung ist der zentrale Mechanismus, der Datenbank Migrationen idempotent macht: ein wiederholter Aufruf des Runners auf einer bereits aktuellen Datenbank verändert nichts.

Fehler während der Ausführung müssen sofort zum Abbruch führen, bevor spätere Datenbank Migrationen auf einem inkonsistenten Zwischenzustand aufsetzen. Ein guter Runner protokolliert jede erfolgreich ausgeführte Migration einzeln in der Migrationstabelle, direkt nach ihrer Ausführung und nicht erst am Ende des gesamten Laufs, damit ein Abbruch in der Mitte den Fortschritt nicht verliert.


<?php

declare(strict_types=1);

final class MigrationRunner
{
    public function __construct(
        private readonly PDO $pdo,
        private readonly MigrationRepository $repository,
        private readonly string $migrationsPath,
    ) {
    }

    public function run(): void
    {
        $this->repository->ensureTableExists();
        $executed = $this->repository->getExecuted();

        $files = glob($this->migrationsPath . '/*.php');
        sort($files);

        foreach ($files as $file) {
            $name = basename($file, '.php');
            if (in_array($name, $executed, true)) {
                continue;
            }

            require_once $file;
            $className = $this->classNameFromFile($name);
            /** @var Migration $migration */
            $migration = new $className();

            echo "Running migration: {$name}\n";
            $migration->up($this->pdo);
            $this->repository->markExecuted($name);
        }
    }

    private function classNameFromFile(string $name): string
    {
        // Convention: 20260731120000_create_orders_table -> CreateOrdersTable
        $parts = explode('_', $name);
        array_shift($parts);
        return implode('', array_map('ucfirst', $parts));
    }
}

5. Rollback: down() sicher und nachvollziehbar umsetzen

Ein Rollback macht die zuletzt ausgeführten Datenbank Migrationen in umgekehrter Reihenfolge rückgängig, indem die down()-Methoden aufgerufen und die entsprechenden Einträge aus der Migrationstabelle entfernt werden. Dieser Mechanismus ist besonders wertvoll direkt nach einem fehlgeschlagenen Deployment, wenn eine neue Migration Probleme verursacht hat und schnell der vorherige Zustand wiederhergestellt werden muss.

Wichtig ist, dass down()-Methoden von Anfang an sorgfältig geschrieben werden, nicht erst dann, wenn ein Rollback tatsächlich gebraucht wird. In der Praxis wird down() selten getestet, bis der Ernstfall eintritt, und genau dann darf es nicht scheitern. Ein DROP COLUMN in down() ist zudem destruktiv: Daten in dieser Spalte gehen verloren, was bei Rollbacks in Produktionsumgebungen mit Datenverlust einhergehen kann und vorher klar kommuniziert werden muss.


<?php

declare(strict_types=1);

public function rollback(int $steps = 1): void
{
    $executed = array_reverse($this->repository->getExecuted());
    $toRollback = array_slice($executed, 0, $steps);

    foreach ($toRollback as $name) {
        $file = $this->migrationsPath . '/' . $name . '.php';
        require_once $file;
        $className = $this->classNameFromFile($name);
        /** @var Migration $migration */
        $migration = new $className();

        echo "Rolling back: {$name}\n";
        $migration->down($this->pdo);
        $this->repository->markRolledBack($name);
    }
}

6. Transaktionale Migrationen und ihre Grenzen

Idealerweise läuft jede einzelne Migration innerhalb einer Datenbank-Transaktion, sodass ein Fehler mitten in der Ausführung das gesamte Schema unverändert lässt, statt einen halb fertigen Zwischenzustand zu hinterlassen. In PHP mit PDO bedeutet das, beginTransaction() vor dem Aufruf von up() zu starten und je nach Ergebnis commit() oder rollBack() aufzurufen. Für einfache Datenbank Migrationen mit ausschließlich DML- oder einzelnen DDL-Befehlen funktioniert dieses Muster zuverlässig.

Eine wichtige Einschränkung betrifft MySQL: DDL-Befehle wie CREATE TABLE oder ALTER TABLE lösen dort implizite Commits aus, sodass eine Transaktion rund um mehrere DDL-Befehle in derselben Migration nicht wie erwartet funktioniert. PostgreSQL unterstützt transaktionale DDL vollständig, MySQL nicht. Wer Datenbank Migrationen für beide Systeme schreibt, muss diese Einschränkung kennen und Migrationen entsprechend klein und atomar halten, statt sich blind auf Transaktionsschutz zu verlassen.

7. Seed-Daten von strukturellen Migrationen trennen

Strukturelle Datenbank Migrationen, die Tabellen, Spalten und Indizes anlegen, sollten strikt von Seed-Daten getrennt werden, die Testdaten oder Referenzwerte wie Länder- oder Statuslisten einfügen. Der Grund ist Wiederholbarkeit: Eine strukturelle Migration darf genau einmal laufen, ein Seed-Skript dagegen soll oft mehrfach ausführbar sein, etwa um eine lokale Entwicklungsdatenbank zurückzusetzen, ohne das Schema neu aufzubauen.

Ein eigenes Verzeichnis für Seeder-Klassen, getrennt vom Migrationsverzeichnis, macht diese Unterscheidung im Code sichtbar. Seeder-Klassen nutzen idealerweise INSERT ... ON DUPLICATE KEY UPDATE oder eine vorherige TRUNCATE-Anweisung, damit wiederholte Ausführung keine doppelten Datensätze erzeugt, während echte Datenbank Migrationen über die Migrationstabelle strikt einmalig bleiben.

8. Migrationen in CI/CD-Pipelines automatisiert ausführen

In einer CI/CD-Pipeline sollten Datenbank Migrationen als eigener, expliziter Schritt vor dem eigentlichen Anwendungs-Deployment laufen, niemals implizit beim ersten Seitenaufruf einer neuen Version. Der Migrations-Runner wird dazu über ein CLI-Skript aufgerufen, dessen Exit-Code über Erfolg oder Misserfolg entscheidet, sodass die Pipeline bei einem Fehler in der Migration den weiteren Rollout automatisch stoppt.

Für produktive Deployments mit mehreren Anwendungsservern ist zusätzlich eine Sperre notwendig, damit nicht zwei Server gleichzeitig dieselben Datenbank Migrationen ausführen. Ein einfacher Lock-Eintrag in der Migrationstabelle oder ein Datenbank-Advisory-Lock verhindert Race Conditions, bei denen zwei Prozesse dieselbe Migration parallel starten und sich gegenseitig ins Gehege kommen.

9. Migrationen im Vergleich: Eigenbau, Doctrine, Phinx

Ein Eigenbau-System für Datenbank Migrationen ist dann sinnvoll, wenn ein Projekt kein volles ORM benötigt und die Anforderungen an Schema-Verwaltung überschaubar bleiben. Etablierte Tools wie Doctrine Migrations oder das eigenständige Phinx bieten dagegen deutlich mehr Komfort, etwa automatische Generierung von Migrationen aus Entity-Änderungen oder Unterstützung für mehrere Datenbanktreiber gleichzeitig.

Kriterium Eigenbau-Migrationen Doctrine Migrations Phinx
Abhängigkeiten Keine Doctrine ORM/DBAL nötig Eigenständiges Composer-Paket
Diff-Generierung Nicht vorhanden Aus Entity-Metadaten Nur manuell
Rollback-Unterstützung Selbst zu implementieren Eingebaut Eingebaut
Kontrolle über SQL Vollständig Teilweise abstrahiert Weitgehend direkt
Passend für Kleine PHP-Projekte ohne ORM Projekte mit Doctrine ORM Framework-unabhängige Projekte

Wer bereits Doctrine im Projekt einsetzt, profitiert von der direkten Integration von Doctrine Migrations. Für reine PHP-Projekte ohne ORM bleibt ein schlanker Eigenbau oder das eigenständige Phinx die passendere Wahl, weil beide keine zusätzliche ORM-Abhängigkeit voraussetzen und die Datenbank Migrationen als eigenständiges, klar abgegrenztes Werkzeug behandeln.

10. Zusammenfassung

Datenbank Migrationen lösen das Grundproblem, Schema-Änderungen nachvollziehbar, versioniert und automatisiert über mehrere Umgebungen hinweg auszuführen. Die wichtigsten Bausteine eines eigenen Migrationssystems sind eine Migrationstabelle als Zustandsspeicher, Migrationsklassen mit symmetrischen up()- und down()-Methoden, ein Runner, der nur die Differenz ausführt, und eine klare Trennung von strukturellen Migrationen und Seed-Daten.

In CI/CD-Pipelines gehören Datenbank Migrationen als expliziter, überwachter Schritt vor das eigentliche Deployment, mit Sperrmechanismen gegen parallele Ausführung bei mehreren Anwendungsservern. Wer die MySQL-spezifischen Grenzen transaktionaler DDL-Befehle kennt und down()-Methoden von Anfang an sorgfältig pflegt, hat mit wenig Code ein robustes, framework-unabhängiges Migrationssystem, das sich bei Bedarf jederzeit durch Doctrine Migrations oder Phinx ersetzen lässt.

Datenbank Migrationen ohne Framework — Das Wichtigste auf einen Blick

Migrationstabelle

Protokolliert ausgeführte Migrationen, wird beim ersten Lauf automatisch angelegt.

up() & down()

Symmetrische Methoden pro Migration, down() von Anfang an sorgfältig testen.

Transaktionen

MySQL committet DDL implizit, PostgreSQL unterstützt transaktionale DDL vollständig.

CI/CD

Migrationen als expliziter Pipeline-Schritt mit Sperre gegen parallele Ausführung.

11. FAQ: Datenbank Migrationen ohne Framework

1Was sind Datenbank Migrationen genau?
Versionierte Schema-Änderungen im Code, per Skript in fester Reihenfolge auf jeder Umgebung ausgeführt, statt manuellem ALTER TABLE.
2Wozu eine eigene Migrationstabelle?
Sie protokolliert bereits ausgeführte Migrationen, damit erneute Läufe nur die Differenz anwenden.
3Warum braucht jede Migration down()?
down() ermöglicht kontrollierten Rollback nach einem fehlgeschlagenen Deployment.
4Kann man Migrationen transaktional ausführen?
Bei PostgreSQL ja, bei MySQL committet DDL implizit, Transaktionsschutz über mehrere DDL-Befehle funktioniert dort nicht.
5Sollten Seed-Daten mitlaufen?
Nein, ein eigenes Seeder-Verzeichnis trennt einmalige Struktur von wiederholbaren Seed-Daten.
6Wie verhindert man Doppelausführung bei mehreren Servern?
Über einen Lock-Mechanismus wie einen Datenbank-Advisory-Lock vor dem Start des Runners.
7Wie wird die Reihenfolge bestimmt?
Über einen Zeitstempel im Dateinamen, der alphabetische und chronologische Sortierung angleicht.
8Was passiert bei einem Fehler mitten in der Ausführung?
Der Runner sollte abbrechen, die Migration nicht als ausgeführt markieren und keine Folgemigrationen starten.
9Wann lohnt sich Eigenbau statt Doctrine oder Phinx?
Bei kleinen Projekten ohne ORM und überschaubarem Migrationsvolumen.
10Gehören Migrationen in die CI/CD-Pipeline?
Ja, als expliziter, überwachter Schritt vor dem Deployment, nie implizit beim ersten Request.