Skip to content

Migrations

Schema-Aenderungen werden in newmeta immer ueber SQL-Migrationen ausgerollt. Nie per update()-Methode in bootstrap.php, nie per direkten INSERT im Plugin-Code. Diese Seite zeigt, wo Migrationen liegen, wie sie benannt sind und wie der MigrationRunner sie ausfuehrt.

Drei Scopes

Der MigrationRunner scannt drei Verzeichnisse:

ScopePfadInhalt
Core_migrations/core/System-Migrationen (Baseline, Core-Tabellen wie _migrations, sessions, api_keys, …)
Plugins zentral_migrations/plugins/{name}/Migrationen, die mit dem Core-Bundle ausgeliefert werden
Plugin-eigen_public/extensions/core/backend/{plugin}/migrations/Migrationen, die mit dem Plugin selbst leben

Existieren fuer denselben Plugin-Namen Dateien in beiden Quellen, werden sie gemerged: andere Dateinamen laufen additiv, gleiche Dateinamen (z. B. 001_create_tables.sql in zentral und plugin-eigen) werden per "later source wins"-Regel von der plugin-eigenen Variante ueberschrieben. Plugins koennen also zentrale Migrationen gezielt lokal patchen, ohne die Core-Historie zu ersetzen.

Naming-Konvention

_migrations/core/
├── 001_baseline.sql
├── 002_add_2fa_columns.sql
├── 003_create_api_keys.sql
├── 004_create_allowed_origins.sql
├── ...
└── 018_widen_consent_text_columns.sql
  • Nummer: dreistellig, fuehrende Nullen (001, 002, …). Innerhalb eines Scopes werden Dateien alphabetisch sortiert — dreistellig sorgt fuer korrekte Reihenfolge bis 999 Migrationen.
  • Unterstriche statt Leerzeichen oder Bindestriche.
  • Sprechende Namen: 002_add_2fa_columns.sql statt 002_update.sql.
  • Eine logische Aenderung pro Datei — lieber 008_add_blog_visible.sql + 009_add_spacing_and_flex.sql als ein Monster.

Beispiel aus dem echten Repo (_migrations/core/): 18 Migrationen, jede thematisch fokussiert.

Die _migrations-Tabelle

Jede erfolgreich ausgefuehrte Migration wird in der _migrations-Tabelle protokolliert:

sql
CREATE TABLE IF NOT EXISTS `_migrations` (
  `id`          INT AUTO_INCREMENT PRIMARY KEY,
  `scope`       VARCHAR(100) NOT NULL,      -- 'core' oder Plugin-Name
  `migration`   VARCHAR(255) NOT NULL,      -- Dateiname
  `batch`       INT NOT NULL DEFAULT 1,     -- Batch-Nummer (ein Run = ein Batch)
  `executed_at` DATETIME DEFAULT CURRENT_TIMESTAMP,
  `checksum`    VARCHAR(64) DEFAULT NULL,   -- SHA-256 der Datei
  UNIQUE KEY `scope_migration` (`scope`, `migration`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

Der UNIQUE KEY auf (scope, migration) verhindert doppelte Ausfuehrung. Der checksum erkennt, wenn jemand eine bereits ausgefuehrte Migration nachtraeglich aendert — der Runner meldet das als Fehler.

Idempotenz: IF NOT EXISTS ueberall

Migrationen muessen idempotent sein. Der Runner fuehrt eine Migration nie zweimal aus, aber die DB darf trotzdem in Zwischenzustaenden sein (z. B. teil-migriert nach Absturz).

Korrekte Idempotenz-Patterns:

sql
-- Tabelle anlegen
CREATE TABLE IF NOT EXISTS my_articles (
  id         INT AUTO_INCREMENT PRIMARY KEY,
  title      VARCHAR(255) NOT NULL,
  body       TEXT,
  created_at DATETIME DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

-- Spalte hinzufuegen
ALTER TABLE my_articles ADD COLUMN IF NOT EXISTS language_short VARCHAR(5) DEFAULT 'de';

-- Index anlegen
CREATE INDEX IF NOT EXISTS idx_lang ON my_articles (language_short);

-- Spalte entfernen (vorsichtig!)
ALTER TABLE my_articles DROP COLUMN IF EXISTS old_column;

IF NOT EXISTS nicht universell verfuegbar

CREATE TABLE IF NOT EXISTS funktioniert ueberall. Aber ALTER TABLE ... ADD COLUMN IF NOT EXISTS erst ab MySQL 8.0.29 / MariaDB 10.3. Fuer aeltere Instanzen: der MigrationRunner ignoriert die MySQL-Fehlercodes 1050 (Table already exists), 1060 (Duplicate column name) und 1061 (Duplicate key) — die Migration laeuft also auch ohne IF NOT EXISTS erfolgreich durch. IF NOT EXISTS bleibt trotzdem die sauberere Wahl, wenn die MySQL-Version sie unterstuetzt.

Plugin-Migrationen

Ein Plugin legt seine eigenen Migrationen unter _public/extensions/core/backend/{plugin}/migrations/ ab:

_public/extensions/core/backend/menueditor/
├── bootstrap.php
├── layout/
└── migrations/
    └── 001_create_tables.sql

Die erste Migration hat typischerweise alle Tabellen, die das Plugin braucht:

sql
-- 001_create_tables.sql — menueditor-Plugin

CREATE TABLE IF NOT EXISTS menueditor_menus (
  id          INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
  web_id      INT UNSIGNED NOT NULL,
  name        VARCHAR(100) NOT NULL DEFAULT 'Main Menu',
  slug        VARCHAR(100) NOT NULL DEFAULT 'main',
  settings    JSON NULL,
  created_at  DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
  updated_at  DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  UNIQUE KEY idx_slug (web_id, slug)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

CREATE TABLE IF NOT EXISTS menueditor_items (
  id              INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
  menu_id         INT UNSIGNED NOT NULL,
  base_id         INT UNSIGNED NULL,
  language_short  VARCHAR(5) NOT NULL DEFAULT 'de',
  parent_id       INT UNSIGNED NULL DEFAULT NULL,
  sort_order      INT UNSIGNED NOT NULL DEFAULT 0,
  item_type       ENUM('link','page','anchor','label','megamenu') NOT NULL DEFAULT 'link',
  label           VARCHAR(500) NOT NULL DEFAULT '',
  -- ... weitere Spalten
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

Echte Quelle: _public/extensions/core/backend/menueditor/migrations/001_create_tables.sql.

Spaetere Aenderungen kommen als neue Dateien dazu (002_add_icon_field.sql, 003_add_unique_slug_constraint.sql, …) — niemals eine bereits gelaufene Migration editieren.

Multi-Language: base_id + language_short

Soll eine Tabelle mehrsprachig sein (erkennbar an "multilanguage":"1" in den Plugin-Buttons, siehe Buttons), braucht sie zwei Pflicht-Spalten:

sql
CREATE TABLE my_articles (
  id             INT AUTO_INCREMENT PRIMARY KEY,
  base_id        INT NULL,               -- NULL oder = id fuer Basis-Record
  language_short VARCHAR(5) NOT NULL DEFAULT 'de',
  title          VARCHAR(255) NOT NULL,
  body           TEXT,
  -- ...
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
  • Basis-Record: base_id IS NULL oder base_id = id (Hauptsprache)
  • Sprachvariante: base_id = {basis-id}, eigenes language_short

Die Core-API (backend/item) uebernimmt das Pattern automatisch — INSERT in Basis-Sprache setzt base_id = neue id, INSERT in einer anderen Sprache erzeugt einen Varianten-Record mit gesetztem base_id.

Mehr Details im php-backend-check-Skill im Repo oder in der Implementierung von backend/item.

Der MigrationRunner — wie wird ausgefuehrt?

Der Runner haengt unter zwei Wegen:

  1. Update Manager UI: /admin/update → "Migrations ausfuehren"-Button
  2. CLI (idealerweise im Deploy-Skript):
bash
php console/bin scheduled-tasks --time-limit=60

Hinter beiden liegt MigrationRunner::runAll(). Kernschritte:

1. acquireLock()           # var/migration.lock mit flock(LOCK_EX | LOCK_NB)
2. ensureTable()           # CREATE TABLE IF NOT EXISTS _migrations
3. getNextBatch()          # MAX(batch) + 1
4. Fuer jeden Scope (core, plugins-zentral, plugin-eigen):
   - Verzeichnis scannen, Dateien alphabetisch sortieren
   - Bereits ausgefuehrte (laut _migrations) ueberspringen
   - SHA-256 der neuen Datei berechnen
   - Datei ausfuehren, bei Erfolg in _migrations registrieren
5. releaseLock()

Lock-Mechanismus: Paralleler Runner-Aufruf (z. B. Admin klickt doppelt) wird per flock() auf var/migration.lock blockiert — nur ein Runner laeuft zur Zeit.

Checksum-Schutz: Aendert jemand eine bereits ausgefuehrte Migration nachtraeglich, passt der SHA-256-Hash nicht mehr zum gespeicherten — der Runner meldet das als Fehler und bricht ab. Nie bestehende Migrationen editieren, immer eine neue schreiben.

Rollback

Der MigrationRunner unterstuetzt keinen automatischen Rollback. Praxis: neue "Kompensations-Migration" schreiben, die die Aenderung rueckgaengig macht.

sql
-- 003_add_feature_flag.sql (falsch)
ALTER TABLE users ADD COLUMN feature_x_enabled TINYINT(1) DEFAULT 0;

-- 004_remove_feature_flag.sql (Kompensation)
ALTER TABLE users DROP COLUMN IF EXISTS feature_x_enabled;

Beide Dateien bleiben im Repo — so bleibt die Historie nachvollziehbar, und der Runner kann neue Instanzen korrekt aufbauen.

Plugin-Aktivierung vs. Migration-Ausfuehrung

Der MigrationRunner laeuft getrennt vom Plugin-Install. installPlugin() ruft zwar installDatabase() auf, aber das ist ein Legacy-Hook, der nur das Property $this->mysqlInstall (inline-SQL im Plugin) ausfuehrt — der migrations/-Ordner des Plugins wird dabei nicht angefasst:

php
// install_controller.php::installDatabase()
public function installDatabase()
{
    if (!empty($this->mysqlInstall)) {
        multiQuery($this->mysqlInstall);
    }
}

Die eigentlichen Plugin-Migrationen werden erst beim naechsten globalen MigrationRunner-Durchlauf abgearbeitet:

  1. Admin triggert /admin/update → "Migrations ausfuehren"
  2. oder CLI: php console/bin scheduled-tasks --time-limit=60

Frisch aktiviertes Plugin hat noch keine Tabellen

Wer gerade ein Plugin mit eigenem migrations/001_create_tables.sql aktiviert hat, muss danach noch den Update-Manager triggern oder den CLI-Runner starten — sonst existieren die DB-Tabellen noch nicht und API-Endpunkte werfen Table doesn't exist. Ein guter Deploy-Workflow fuehrt den MigrationRunner direkt nach dem Plugin-Aktivieren aus.

Haeufige Fehler

Migration nach Ausfuehrung editieren

Tut man das, stimmt der checksum in _migrations nicht mehr. Der naechste Runner-Aufruf wirft einen Fehler und bricht ab. Fix: UPDATE _migrations SET checksum = '...' WHERE scope = '…' AND migration = '…' oder — sauberer — die Aenderung als neue Datei einspielen.

Nummer wiederverwenden

Zwei Dateien mit 003_* im selben Scope fuehren zu undefinierter Reihenfolge (alphabetisch nach Rest-Name). In Teams immer kurz checken, welche Nummer als naechste frei ist — idealerweise per ls _migrations/core | tail -3 vor dem Anlegen.

DROP ohne Check

DROP TABLE xy bricht ab, wenn die Tabelle schon weg ist. Lieber DROP TABLE IF EXISTS xy. Analog DROP COLUMN IF EXISTS, DROP INDEX IF EXISTS.

Datenbank-Snapshot vor grosser Migration

Bei ALTER TABLE auf Produktion mit vielen Zeilen (>1 Mio) kann die Migration minutenlang laufen und Writes blockieren. Fuer grosse Aenderungen: Deploy ausserhalb der Peak-Zeiten, vorher DB-Dump, lange Migrationen auf Staging testen.

Foreign Keys in Migrationen

Der MigrationRunner ignoriert 1050/1060/1061, aber nicht FK-Constraint-Fehler. Wenn ein FK auf eine noch nicht existierende Tabelle zeigt, bricht die Migration ab. Reihenfolge beachten: Parent-Tabelle in frueherer Migration oder oben in derselben Datei.

Siehe auch