Datenmigration mit PHP: Altdaten sicher in neue Systeme überführen
Datenmigration mit PHP: Altdaten sicher in neue Systeme überführen
“Die Daten aus dem alten System müssen mit.” - Dieser Satz klingt harmlos. In der Praxis ist die Datenmigration oft der Teil eines Projekts, der am meisten unterschätzt wird. Nicht wegen der Technik, sondern wegen der Datenqualität.
Das eigentliche Problem: Alte Systeme speichern Daten anders als neue. Feldnamen stimmen nicht überein, Formate sind inkonsistent, Pflichtfelder fehlen, und in jeder zweiten Datenbank finden sich Einträge, die seit Jahren niemand mehr angefasst hat. Eine Migration ist keine Kopieraktion - sie ist eine Übersetzung zwischen zwei Welten.
Für Entscheider: Warum Migration eigene Planung braucht
Datenmigration wird in Projektplänen regelmäßig als “Daten übernehmen - 2 Tage” geschätzt. In der Realität sieht das anders aus:
| Was geplant war | Was tatsächlich passiert |
|---|---|
| CSV-Export aus Altsystem | Export liefert kaputte Umlaute, fehlende Spalten, doppelte Einträge |
| ”Daten einfach importieren” | Zahlreiche Datensätze scheitern an der Validierung im neuen System |
| ”Läuft an einem Wochenende” | Drei Migrations-Durchläufe nötig, bis alles stimmt |
| ”Betrifft nur die IT” | Fachabteilung muss Zuordnungen klären, Altdaten bereinigen, Ergebnisse prüfen |
Die Konsequenz: Eine Migration, die nicht sauber geplant wird, verzögert den Go-Live. Oder - schlimmer - das neue System startet mit fehlerhaften Daten. Kunden mit falschen Adressen, Rechnungen ohne Zuordnung, Lagerbestände, die nicht stimmen.
Faustregel: Planen Sie für die Datenmigration mindestens den Aufwand des Import-Features selbst ein (Mapping-Code + Validierung + Testing). Bei Legacy-Systemen mit gewachsenen Daten oft deutlich mehr.
Die vier Phasen einer Migration
Phase 1: Analyse - Was haben wir überhaupt?
Bevor Sie eine Zeile Migrations-Code schreiben, müssen Sie die Altdaten verstehen. Nicht die Dokumentation, nicht das ER-Diagramm von 2015 - die tatsächlichen Daten.
Was Sie prüfen sollten:
// Grundlegende Datenqualität checken
$stats = $db->query("
SELECT
COUNT(*) as total,
COUNT(DISTINCT email) as unique_emails,
SUM(CASE WHEN email IS NULL OR email = '' THEN 1 ELSE 0 END) as missing_email,
SUM(CASE WHEN created_at IS NULL THEN 1 ELSE 0 END) as missing_date,
MIN(created_at) as oldest_record,
MAX(created_at) as newest_record
FROM kunden
");
Typische Entdeckungen in dieser Phase:
- 15% der E-Mail-Adressen sind leer oder ungültig
- Drei verschiedene Datumsformate im selben Feld (
2024-01-15,15.01.2024,01/15/2024) - Kundennummern, die im alten System eindeutig waren, kollidieren mit dem Nummernkreis des neuen Systems
- Felder, die laut Dokumentation Pflicht sind, aber in der Praxis leer bleiben
Wichtig: Datenbereinigung gehört in diese Phase, nicht ans Ende. Duplikate, veraltete Einträge und Inkonsistenzen mit der Fachabteilung klären, BEVOR die Migration startet - sonst migrieren Sie den Müll gleich mit.
Ergebnis: Ein Mapping-Dokument, das beschreibt, welches Feld im Altsystem welchem Feld im neuen System entspricht - und was mit den Problemfällen passiert.
Phase 2: Mapping - Die Übersetzungstabelle
Das Mapping ist das Herzstück der Migration. Es definiert nicht nur welches Feld wohin wandert, sondern auch wie die Werte transformiert werden.
// Mapping-Konfiguration
$fieldMapping = [
// Einfach: 1:1 Übernahme
'Vorname' => 'first_name',
'Nachname' => 'last_name',
// Transformation: Format ändern
'Geburtsdatum' => [
'target' => 'birth_date',
'transform' => fn($val) => self::parseGermanDate($val) // 15.01.1990 → 1990-01-15
],
// Zusammenführung: Zwei Felder → eins
'PLZ' => [
'target' => 'address_zip',
'transform' => fn($val) => str_pad($val, 5, '0', STR_PAD_LEFT) // 1234 → 01234
],
// Lookup: Wert durch Referenz ersetzen
'Abteilung' => [
'target' => 'department_id',
'transform' => fn($val) => $this->departmentLookup[$val] ?? null
],
];
Häufige Transformationen:
- Datumsformate vereinheitlichen
- PLZ mit führender Null auffüllen
- Anrede normalisieren (“Hr.”, “Herr”, “herr”, “M” →
male) - Telefonnummern in E.164-Format bringen (+49…)
- Veraltete Kategorien auf neue Struktur mappen
Phase 3: Migration ausführen - Chunk für Chunk
Nie alles auf einmal. Immer in Tranchen.
class DataMigrator
{
// 500 = Balance zwischen Memory-Usage und DB-Overhead
// Größer: schneller, aber mehr RAM. Kleiner: weniger RAM, aber mehr Queries.
private int $chunkSize = 500;
private array $errors = [];
private array $stats = ['migrated' => 0, 'skipped' => 0, 'failed' => 0];
public function migrate(string $source): void
{
$offset = 0;
while ($rows = $this->fetchChunk($source, $offset, $this->chunkSize)) {
$this->db->beginTransaction();
try {
foreach ($rows as $row) {
$mapped = $this->applyMapping($row);
try {
$validated = $this->validate($mapped);
$this->insertOrUpdate($validated);
$this->stats['migrated']++;
} catch (ValidationException $e) {
$this->stats['skipped']++;
$this->errors[] = "Row {$row['id']}: {$e->getMessage()}";
}
}
$this->db->commit();
} catch (\Exception $e) {
$this->db->rollBack();
$this->stats['failed'] += count($rows);
$this->errors[] = "Chunk ab Offset $offset: " . $e->getMessage();
}
$offset += $this->chunkSize;
$this->logProgress();
}
}
private function logProgress(): void
{
$total = array_sum($this->stats);
echo sprintf(
"[%s] Fortschritt: %d verarbeitet | %d migriert | %d übersprungen | %d fehlgeschlagen\n",
date('H:i:s'), $total,
$this->stats['migrated'], $this->stats['skipped'], $this->stats['failed']
);
}
}
Warum Chunks statt Bulk-Insert:
- Memory bleibt konstant (kein OOM bei 500.000 Datensätzen)
- Einzelne fehlerhafte Chunks lassen sich wiederholen, ohne alles neu zu starten
- Fortschritt ist sichtbar und unterbrechbar
- Datenbank-Locks bleiben kurz
Gute Migrationen sind wiederholbar: Ein erneuter Lauf darf keine doppelten Datensätze oder unkontrollierten Seiteneffekte erzeugen. Nutzen Sie INSERT ... ON CONFLICT (PostgreSQL) oder INSERT ... ON DUPLICATE KEY UPDATE (MySQL), damit Probeläufe und finaler Lauf sauber hintereinander funktionieren.
Für komplexe Migrationen lohnt sich außerdem ein Dry-Run-Modus, der Mapping, Validierung und Logging komplett ausführt, aber noch nicht ins Zielsystem schreibt. So sehen Sie vorab, wie viele Datensätze durchkommen und wo es klemmt.
Phase 4: Validierung - Stimmen die Zahlen?
Nach der Migration: Zählen, vergleichen, stichprobenartig prüfen.
// Automatische Validierung nach Migration
$checks = [
'Kunden gesamt' => [
'source' => "SELECT COUNT(*) FROM alt_kunden",
'target' => "SELECT COUNT(*) FROM kunden WHERE migrated = true",
],
'Kunden mit E-Mail' => [
'source' => "SELECT COUNT(*) FROM alt_kunden WHERE email != ''",
'target' => "SELECT COUNT(*) FROM kunden WHERE migrated = true AND email IS NOT NULL",
],
'Umsatz gesamt' => [
'source' => "SELECT SUM(betrag) FROM alt_rechnungen",
'target' => "SELECT SUM(amount) FROM invoices WHERE migrated = true",
],
];
foreach ($checks as $label => $queries) {
$sourceCount = $sourceDb->query($queries['source'])->fetchColumn();
$targetCount = $targetDb->query($queries['target'])->fetchColumn();
$match = $sourceCount == $targetCount ? 'OK' : 'ABWEICHUNG';
echo "$label: Quelle=$sourceCount, Ziel=$targetCount [$match]\n";
}
Was automatische Checks nicht finden: Inhaltliche Fehler. Ob “Müller GmbH” im neuen System dem richtigen Kunden zugeordnet ist, kann nur ein Mensch prüfen. Planen Sie Stichproben durch die Fachabteilung ein.
Datenquellen: CSV, Excel, API, Datenbank
CSV/Excel: Die häufigste Quelle
In der Praxis kommt der Altdaten-Export oft als CSV oder Excel. Das klingt einfach, hat aber Tücken:
// CSV einlesen mit Encoding-Handling
function readCsv(string $file, string $delimiter = ';', string $sourceEncoding = 'ISO-8859-1'): \Generator
{
$handle = fopen($file, 'r');
if (!$handle) {
throw new \RuntimeException("Could not open file: $file");
}
try {
// BOM entfernen (Excel-Export)
$bom = fread($handle, 3);
if ($bom !== "\xEF\xBB\xBF") {
rewind($handle);
}
// Encoding konvertieren - Quell-Encoding explizit angeben
$header = fgetcsv($handle, 0, $delimiter);
$header = array_map(fn($h) => mb_convert_encoding(trim($h), 'UTF-8', $sourceEncoding), $header);
while ($row = fgetcsv($handle, 0, $delimiter)) {
if (count($row) !== count($header)) continue; // Kaputte Zeilen überspringen
$row = array_map(fn($v) => mb_convert_encoding(trim($v), 'UTF-8', $sourceEncoding), $row);
yield array_combine($header, $row);
}
} finally {
fclose($handle);
}
}
Wichtig: mb_detect_encoding() ist keine sichere Erkennung, sondern eine Heuristik - bei kurzen Strings oder reinem ASCII liegt sie oft falsch. Besser: Encoding aus der Export-Konfiguration des Altsystems erfragen oder einen fixen Wert verwenden. Windows-Exporte liegen häufig in ISO-8859-1 oder Windows-1252 vor.
In produktiven Migrationen brauchen CSV-Importer zusätzlich Logging für kaputte Zeilen, Duplikatprüfung in der Kopfzeile und eine nachvollziehbare Fehlerliste. Der Code oben zeigt das Prinzip, nicht die produktionsreife Bibliothek.
Typische CSV-Probleme:
- Encoding: Windows-Export liefert ISO-8859-1 oder Windows-1252, neues System erwartet UTF-8
- Trennzeichen: Semikolon (deutsch) vs. Komma (international) vs. Tab
- BOM: Excel fügt unsichtbare Bytes am Dateianfang ein
- Zeilenumbrüche in Feldern: Adressen mit Zeilenumbruch sprengen naive CSV-Parser
Legacy-Datenbank: Direktzugriff
Wenn Sie Zugriff auf die Altdatenbank haben, ist das der sauberste Weg. Kein Export-Schritt, keine Encoding-Probleme, Daten sind typisiert.
// Zwei Datenbanken gleichzeitig ansprechen
$sourceDb = new PDO('mysql:host=alt-server;dbname=legacy_app', 'readonly', '...');
$targetDb = new PDO('pgsql:host=localhost;dbname=neue_app', 'migrator', '...');
// Read-Only auf der Quelle - Sicherheitsnetz
$sourceDb->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
// PostgreSQL:
$sourceDb->exec("SET SESSION TRANSACTION READ ONLY");
// MySQL (5.6+):
// $sourceDb->exec("SET SESSION tx_read_only = 1");
Die genaue Read-Only-Session-Konfiguration hängt vom Quellsystem ab (siehe Kommentare im Code).
Wichtig: Lesezugriff auf die Produktivdatenbank nur mit Read-Only-User und außerhalb der Geschäftszeiten. Oder besser: gegen ein Backup migrieren.
API: Wenn kein Datenbankzugriff möglich ist
Manche Altsysteme bieten nur eine API. Das ist langsamer, aber manchmal die einzige Option.
// API-basierte Migration mit Rate-Limiting
function fetchFromApi(string $endpoint, int $page = 1): array
{
static $lastRequest = 0;
// Rate-Limit: max 10 Requests/Sekunde
$elapsed = microtime(true) - $lastRequest;
if ($elapsed < 0.1) {
usleep((int)((0.1 - $elapsed) * 1_000_000));
}
$context = stream_context_create(['http' => ['timeout' => 30]]);
$response = file_get_contents("$endpoint?page=$page&limit=100", false, $context);
$lastRequest = microtime(true);
// 429 Too Many Requests: Retry-After beachten
if ($response === false) {
$headers = $http_response_header ?? [];
foreach ($headers as $h) {
if (stripos($h, '429') !== false) {
sleep(60); // Oder Retry-After-Header auswerten
return fetchFromApi($endpoint, $page);
}
}
throw new \RuntimeException("API request failed: $endpoint?page=$page");
}
return json_decode($response, true);
}
API-Migration ist langsam. 100.000 Datensätze bei 100 pro Request und 10 Requests/Sekunde = rechnerisch ca. 17 Minuten nur fürs Lesen. In der Praxis kommen Pagination-Fehler, Rate-Limits, Retry-Logik und instabile Endpunkte dazu - planen Sie deutlich mehr Zeit ein.
Rollback: Der Plan B
Jede Migration braucht einen Rückweg. Nicht weil es schiefgehen wird, sondern weil es schiefgehen kann.
Drei Strategien:
A) Batch-Flag (für die meisten einmaligen Migrationen der pragmatischste Ansatz):
// Migrierte Datensätze markieren statt direkt einmischen
$stmt = $targetDb->prepare("
INSERT INTO kunden (first_name, last_name, email, migrated, migration_batch)
VALUES (?, ?, ?, true, ?)
");
Rollback: DELETE FROM kunden WHERE migration_batch = '2026-02-16_001'. Sauber, gezielt, keine Seiteneffekte.
B) Datenbank-Snapshot vor Migration:
# PostgreSQL: Snapshot vor der Migration
pg_dump -Fc neue_app > backup_vor_migration.dump
# Rollback: Komplette Wiederherstellung
pg_restore -d neue_app --clean backup_vor_migration.dump
C) Parallelbetrieb: Alte und neue Datenbank laufen parallel. Erst nach erfolgreicher Validierung wird umgeschaltet. Aufwändiger, aber das sicherste Vorgehen bei geschäftskritischen Daten.
Häufige Stolperfallen
1. Encoding-Chaos
Das mit Abstand häufigste Problem. Der Klassiker: Umlaute gehen kaputt, weil Quelle und Ziel unterschiedliche Encodings verwenden.
Lösung: Encoding-Konvertierung als ersten Schritt in der Pipeline, nicht als Nachgedanken. Und immer gegen echte Daten testen, nicht gegen Testdaten ohne Sonderzeichen.
2. Primärschlüssel-Konflikte
Das alte System hat Kundennummer 1-50.000. Das neue System hat bereits Kunden mit IDs 1-200. Die Migration überschreibt bestehende Datensätze.
Lösung: Neue IDs generieren und die alte ID als Referenz in einem separaten Feld (legacy_id) speichern. Nie die alten IDs im neuen System weiterverwenden.
3. Referenzielle Integrität
Kunde 4711 hat Aufträge. Der Kunde wird migriert, bekommt neue ID 89012. Die Aufträge verweisen noch auf 4711. Ergebnis: Verwaiste Datensätze.
Lösung: ID-Mapping-Tabelle führen und Referenzen in der richtigen Reihenfolge auflösen - erst Stammdaten (Kunden, Produkte), dann Bewegungsdaten (Aufträge, Rechnungen).
// ID-Mapping während der Migration
$idMap = [];
// Erst Kunden migrieren
foreach ($altKunden as $kunde) {
$neueId = $this->insertKunde($kunde);
$idMap['kunden'][$kunde['alte_id']] = $neueId;
}
// Dann Aufträge mit gemappten IDs
foreach ($altAuftraege as $auftrag) {
$auftrag['kunden_id'] = $idMap['kunden'][$auftrag['alte_kunden_id']];
$this->insertAuftrag($auftrag);
}
4. Zeitdruck am Go-Live-Wochenende
Migration zum ersten Mal am Live-Wochenende ausführen = Risiko. Wenn die Migration 8 Stunden dauert statt der geplanten 2, ist Montag früh nicht alles fertig.
Lösung: Mindestens zwei vollständige Probeläufe mit einem aktuellen Produktions-Dump (bei personenbezogenen Daten anonymisiert) vor dem Go-Live. Testdaten ohne Sonderzeichen, Duplikate und Inkonsistenzen decken die echten Probleme nicht auf. Dabei die Laufzeit messen und einen Puffer von mindestens 50% einplanen.
5. “Die alten Daten sind sauber”
Sind sie nicht. Nie. In keinem Projekt, das ich bisher gesehen habe.
Lösung: Datenqualitäts-Checks vor der Migration laufen lassen und die Ergebnisse mit der Fachabteilung besprechen. Gemeinsam entscheiden: bereinigen, transformieren oder überspringen?
Wann sich eine eigene Migrations-Pipeline lohnt
Eigener Migrations-Code lohnt sich:
- Mehr als 10.000 Datensätze
- Komplexe Transformationen (Formatwechsel, Zusammenführungen, Lookups)
- Migration muss wiederholbar sein (Probeläufe + finaler Lauf)
- Mehrere Quellen fließen in ein Zielsystem
Ein einfaches SQL-Script reicht:
- Wenige hundert Datensätze
- 1:1-Übernahme ohne Transformation
- Einmalige Aktion ohne Rollback-Bedarf
ETL-Tools (Talend, Apache NiFi, Pentaho) lohnen sich:
- Regelmäßige Datenübernahme (nicht einmalig, sondern dauerhaft)
- Mehrere Quellsysteme mit unterschiedlichen Formaten
- Team ohne eigene Entwicklerkapazität für Migration
Für SQL-basierte Transformationen und Analytics-Pipelines eignet sich dbt - das ist aber eher ein ELT-Framework für wiederkehrende Datenprozesse als ein klassisches Migrationswerkzeug.
Checkliste: Datenmigration
- Altdaten analysiert und bereinigt? Datenqualität geprüft, Duplikate und Inkonsistenzen mit Fachabteilung geklärt - vor der Migration, nicht danach
- Mapping dokumentiert? Jedes Feld zugeordnet: Quelle → Ziel → Transformation. Encoding geklärt (UTF-8)
- Reihenfolge festgelegt? Stammdaten vor Bewegungsdaten, Referenzen korrekt auflösen
- Rollback-Strategie? Migration-Batch-Flag, Datenbank-Snapshot oder Parallelbetrieb
- Probeläufe mit Produktionsdaten? Mindestens zwei Durchläufe mit aktuellem Dump, Laufzeit gemessen, Zeitfenster reicht
- Validierung automatisiert? Zählungen und Checksummen vergleichen + Stichproben durch Fachabteilung
Datenmigration geplant und unsicher, ob Ihr Ansatz hält? Ich prüfe Ihre Datenquellen, entwerfe das Mapping und führe einen Probelauf durch - bevor am Go-Live-Wochenende die Überraschungen kommen. Kostenloses Erstgespräch anfragen.
Über Carola Schulte
Software-Architektin mit 25+ Jahren Erfahrung. Spezialisiert auf robuste Business-Apps mit PHP/PostgreSQL, Security-by-Design und DSGVO-konforme Systeme. 1,8M+ Lines of Code in Produktion.
Projekt im Kopf?
Lassen Sie uns besprechen, wie ich Ihre Anforderungen umsetzen kann – kostenlos und unverbindlich.
Kostenloses Erstgespräch