Legacy-Modernisierung: Alte Systeme in moderne Web-Apps überführen
Legacy-Modernisierung: So überführen Sie alte Systeme in moderne Web-Apps
Das 15 Jahre alte System läuft noch. Irgendwie. Der ursprüngliche Entwickler ist längst weg, die Dokumentation lückenhaft, und jeder hat Angst, etwas anzufassen. Klingt bekannt?
Sie sind nicht allein. 70% aller Unternehmen kämpfen mit Legacy-Systemen, die zu wichtig sind, um sie abzuschalten, aber zu alt, um sie weiterzuentwickeln.
Die gute Nachricht: Sie müssen nicht alles auf einmal ersetzen. In diesem Artikel zeige ich Ihnen bewährte Patterns und konkreten PHP-Code, um Legacy-Systeme schrittweise zu modernisieren - ohne den laufenden Betrieb zu gefährden.
Warum Big Bang scheitert
Der klassische Ansatz: “Wir bauen das System komplett neu und schalten dann um.” Das scheitert aus drei Gründen:
- Unbekannte Anforderungen: Das alte System macht Dinge, von denen niemand mehr weiß
- Zeitdruck: Während Sie neu bauen, ändert sich das Business
- Risiko: Am Tag X muss alles funktionieren - tut es aber nie
Merksatz: Jede erfolgreiche Migration ist eine schrittweise Migration. Big Bang funktioniert nur in PowerPoint.
Das Strangler Fig Pattern
Das wichtigste Pattern für Legacy-Modernisierung. Benannt nach Würgefeigen, die ihren Wirtsbaum langsam umwachsen und ersetzen:
┌─────────────────────────────────────────┐
│ Proxy/Router │
└─────────────┬───────────────┬───────────┘
│ │
┌────────▼────────┐ ┌────▼────────────┐
│ Neues System │ │ Legacy-System │
│ (Feature A, B) │ │ (Feature C, D) │
└─────────────────┘ └─────────────────┘
Passt Strangler Fig für Ihr Projekt?
- Legacy ist stabil genug, um weiterzulaufen (keine akuten Crashes)
- Neuentwicklung dauert länger als 6 Monate
- Es gibt klare Feature-Schnitte (nicht alles ist mit allem verzahnt)
- Das Datenmodell muss sich sowieso ändern
- Rollback-Fähigkeit ist geschaeftskritisch
Wenn 3+ Punkte zutreffen: Strangler ist Ihr Freund.
Schritt 1: Proxy vorschalten
<?php
declare(strict_types=1);
class MigrationRouter
{
private array $migratedRoutes = [];
private array $featureFlags = [];
public function __construct(
private readonly NewApplication $newApp,
private readonly LegacyProxy $legacyProxy,
private readonly FeatureFlagService $flags,
private readonly LoggerInterface $logger
) {
$this->loadMigratedRoutes();
}
public function handle(Request $request): Response
{
$path = $request->getPathInfo();
$method = $request->getMethod();
// Prüfen: Ist diese Route schon migriert?
if ($this->isRouteMigrated($path, $method)) {
return $this->routeToNewSystem($request);
}
// Prüfen: Ist Feature-Flag aktiv (graduelle Migration)?
if ($this->shouldUseNewSystem($path, $request)) {
return $this->routeToNewSystem($request);
}
// Fallback: Legacy-System
return $this->routeToLegacy($request);
}
private function isRouteMigrated(string $path, string $method): bool
{
$key = strtoupper($method) . ':' . $this->normalizePath($path);
return isset($this->migratedRoutes[$key]);
}
private function shouldUseNewSystem(string $path, Request $request): bool
{
// Feature-Flag basierte Migration (z.B. für 10% der User)
$userId = $request->getSession()->get('user_id');
if (!$userId) {
return false;
}
$flagName = 'migration_' . $this->pathToFlagName($path);
// WICHTIG: percentage gehoert in die Flag-Config, NICHT in den Context!
// Sonst kann ein Bug/Manipulation das Rollout verändern.
return $this->flags->isEnabled($flagName, [
'user_id' => $userId,
]);
}
private function routeToNewSystem(Request $request): Response
{
$startTime = microtime(true);
try {
$response = $this->newApp->handle($request);
$this->logger->info('Request routed to new system', [
'path' => $request->getPathInfo(),
'duration_ms' => (microtime(true) - $startTime) * 1000,
'status' => $response->getStatusCode(),
]);
return $response;
} catch (\Throwable $e) {
$this->logger->error('New system failed, falling back to legacy', [
'path' => $request->getPathInfo(),
'error' => $e->getMessage(),
]);
// Fallback bei Fehler
return $this->routeToLegacy($request);
}
}
private function routeToLegacy(Request $request): Response
{
return $this->legacyProxy->forward($request);
}
private function loadMigratedRoutes(): void
{
// Aus Config oder Datenbank laden
$this->migratedRoutes = [
'GET:/api/v2/users' => true,
'GET:/api/v2/users/*' => true,
'POST:/api/v2/users' => true,
'GET:/dashboard' => true,
// Weitere migrierte Routen...
];
}
private function normalizePath(string $path): string
{
// NICHT: preg_replace('/\/\d+/', '/*', $path)
// Das macht /reports/2025/09 zu /*/* - falsch!
// Besser: Explizite Route-Patterns definieren
// In Produktion: aus zentraler Route-Registry laden, nicht hardcoded!
$patterns = [
'#^/api/v2/users/(\d+)$#' => '/api/v2/users/*',
'#^/api/v2/orders/(\d+)$#' => '/api/v2/orders/*',
'#^/customers/(\d+)/invoices$#' => '/customers/*/invoices',
];
foreach ($patterns as $regex => $normalized) {
if (preg_match($regex, $path)) {
return $normalized;
}
}
return $path; // Unverändert wenn kein Pattern matched
}
private function pathToFlagName(string $path): string
{
// ACHTUNG: /foo-bar und /foo/bar werden beide zu foo_bar!
// In Produktion: stabile Route-ID aus Registry nutzen.
return str_replace(['/', '-'], '_', trim($path, '/'));
}
}
Schritt 2: Legacy-Proxy implementieren
<?php
class LegacyProxy
{
public function __construct(
private readonly string $legacyBaseUrl,
private readonly int $timeout = 30
) {}
public function forward(Request $request): Response
{
$ch = curl_init();
$url = $this->legacyBaseUrl . $request->getRequestUri();
curl_setopt_array($ch, [
CURLOPT_URL => $url,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => $this->timeout,
CURLOPT_CUSTOMREQUEST => $request->getMethod(),
CURLOPT_HTTPHEADER => $this->forwardHeaders($request),
CURLOPT_HEADER => true,
CURLOPT_SSL_VERIFYPEER => true,
CURLOPT_SSL_VERIFYHOST => 2,
]);
// Body forwarden (POST, PUT, etc.)
if (in_array($request->getMethod(), ['POST', 'PUT', 'PATCH'])) {
curl_setopt($ch, CURLOPT_POSTFIELDS, $request->getContent());
}
// Cookies forwarden (wichtig für Session!)
if ($cookies = $request->cookies->all()) {
$cookieString = implode('; ', array_map(
fn($k, $v) => "{$k}={$v}",
array_keys($cookies),
array_values($cookies)
));
curl_setopt($ch, CURLOPT_COOKIE, $cookieString);
}
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$headerSize = curl_getinfo($ch, CURLINFO_HEADER_SIZE);
if ($response === false) {
throw new LegacyUnavailableException(curl_error($ch));
}
curl_close($ch);
$headers = $this->parseHeaders(substr($response, 0, $headerSize));
$body = substr($response, $headerSize);
return new Response($body, $httpCode, $headers);
}
private function forwardHeaders(Request $request): array
{
$forward = [];
// Wichtige Headers forwarden
$allowedHeaders = [
'Content-Type', 'Accept', 'Authorization',
'X-Requested-With', 'X-Correlation-ID'
];
foreach ($allowedHeaders as $header) {
if ($value = $request->headers->get($header)) {
$forward[] = "{$header}: {$value}";
}
}
// Original-IP forwarden
$forward[] = 'X-Forwarded-For: ' . $request->getClientIp();
$forward[] = 'X-Forwarded-Proto: ' . $request->getScheme();
return $forward;
}
private function parseHeaders(string $headerStr): array
{
$headers = [];
$setCookies = []; // Mehrere Set-Cookie Header möglich!
foreach (explode("\r\n", $headerStr) as $line) {
if (str_contains($line, ':')) {
[$key, $value] = explode(':', $line, 2);
$key = trim($key);
$value = trim($value);
// Set-Cookie kommt oft mehrfach - als Array sammeln
if (strcasecmp($key, 'Set-Cookie') === 0) {
$setCookies[] = $value;
} else {
$headers[$key] = $value;
}
}
}
// Session-Cookies gezielt droppen (nicht alle!)
// Sonst killt man Locale, CSRF, Consent, Feature-Toggles etc.
$sessionCookieNames = ['PHPSESSID', 'SESSIONID', 'session_id'];
$filteredCookies = array_filter($setCookies, function($cookie) use ($sessionCookieNames) {
foreach ($sessionCookieNames as $name) {
if (str_starts_with($cookie, $name . '=')) {
return false; // Session-Cookie rausfiltern
}
}
return true; // Andere Cookies behalten
});
if (!empty($filteredCookies)) {
$headers['Set-Cookie'] = $filteredCookies;
}
return $headers;
}
}
Hinweis: Je nach Framework werden Response-Header mehrfach gesetzt.
Set-Cookiemuss als Multi-Header behandelt werden - prueft euer Response-Objekt!
Merksatz: Der Proxy ist Ihr Sicherheitsnetz. Er ermöglicht Rollback in Sekunden, nicht Stunden.
API-Wrapping: Legacy-Funktionen kapseln
Bevor Sie Code migrieren, kapseln Sie ihn. So koennen Sie die Implementierung später austauschen, ohne die Aufrufer zu ändern:
Anti-Corruption Layer
<?php
// Das Legacy-System hat eine chaotische API
class LegacyCustomerService
{
public function getKunde($id)
{
// Gibt Array mit deutschen Keys zurueck:
// ['KundenNr' => '123', 'Firma' => 'Acme', 'PLZ_Ort' => '12345 Berlin', ...]
}
public function speichereKunde($data)
{
// Erwartet Array mit deutschen Keys
// Gibt 1 bei Erfolg, 0 bei Fehler zurueck (kein bool!)
}
}
// Anti-Corruption Layer: Saubere Schnittstelle nach aussen
interface CustomerRepositoryInterface
{
public function findById(int $id): ?Customer;
public function save(Customer $customer): void;
public function findByEmail(string $email): ?Customer;
}
class LegacyCustomerRepository implements CustomerRepositoryInterface
{
public function __construct(
private readonly LegacyCustomerService $legacy,
private readonly LoggerInterface $logger
) {}
public function findById(int $id): ?Customer
{
try {
$data = $this->legacy->getKunde($id);
if (empty($data)) {
return null;
}
return $this->mapToCustomer($data);
} catch (\Throwable $e) {
$this->logger->error('Legacy customer lookup failed', [
'id' => $id,
'error' => $e->getMessage(),
]);
throw new RepositoryException("Customer lookup failed", 0, $e);
}
}
public function save(Customer $customer): void
{
$legacyData = $this->mapToLegacy($customer);
$result = $this->legacy->speichereKunde($legacyData);
// Legacy gibt 1/0 statt true/false zurueck
if ($result !== 1) {
throw new RepositoryException("Failed to save customer");
}
}
public function findByEmail(string $email): ?Customer
{
// WARNUNG: Das ist nur Uebergangs-Code!
// Ab ~1000 Datensaetzen wird das unertraeglich langsam.
// Loesung: Index in Legacy-DB, eigener Query, oder Migration-Job.
$allCustomers = $this->legacy->getAlleKunden();
foreach ($allCustomers as $data) {
if (($data['Email'] ?? '') === $email) {
return $this->mapToCustomer($data);
}
}
return null;
}
private function mapToCustomer(array $legacyData): Customer
{
// PLZ und Ort trennen (Legacy speichert "12345 Berlin")
$plzOrt = $legacyData['PLZ_Ort'] ?? '';
preg_match('/^(\d{5})\s+(.+)$/', $plzOrt, $matches);
return new Customer(
id: (int)($legacyData['KundenNr'] ?? 0),
companyName: $legacyData['Firma'] ?? '',
email: $legacyData['Email'] ?? '',
address: new Address(
street: $legacyData['Strasse'] ?? '',
postalCode: $matches[1] ?? '',
city: $matches[2] ?? '',
country: $legacyData['Land'] ?? 'DE',
),
createdAt: $this->parseLegacyDate($legacyData['Angelegt'] ?? ''),
);
}
private function mapToLegacy(Customer $customer): array
{
return [
'KundenNr' => $customer->id ?: null,
'Firma' => $customer->companyName,
'Email' => $customer->email,
'Strasse' => $customer->address->street,
'PLZ_Ort' => $customer->address->postalCode . ' ' . $customer->address->city,
'Land' => $customer->address->country,
];
}
private function parseLegacyDate(string $date): \DateTimeImmutable
{
// Legacy nutzt DD.MM.YYYY Format
$parsed = \DateTimeImmutable::createFromFormat('d.m.Y', $date);
return $parsed ?: new \DateTimeImmutable();
}
}
// Spaeter: Neue Implementierung, gleiche Schnittstelle
class ModernCustomerRepository implements CustomerRepositoryInterface
{
public function __construct(
private readonly PDO $db
) {}
public function findById(int $id): ?Customer
{
$stmt = $this->db->prepare(
'SELECT * FROM customers WHERE id = ?'
);
$stmt->execute([$id]);
$row = $stmt->fetch(PDO::FETCH_ASSOC);
return $row ? $this->hydrate($row) : null;
}
// ... moderne Implementierung
}
Feature-Toggle zwischen Implementierungen
<?php
class CustomerRepositoryFactory
{
public function __construct(
private readonly LegacyCustomerRepository $legacy,
private readonly ModernCustomerRepository $modern,
private readonly FeatureFlagService $flags
) {}
public function create(?int $userId = null): CustomerRepositoryInterface
{
if ($this->flags->isEnabled('use_modern_customer_repo', ['user_id' => $userId])) {
return $this->modern;
}
return $this->legacy;
}
}
// In Service-Klassen:
class CustomerService
{
public function __construct(
private readonly CustomerRepositoryFactory $repoFactory
) {}
public function getCustomer(int $id, ?int $requestingUserId = null): ?Customer
{
$repo = $this->repoFactory->create($requestingUserId);
return $repo->findById($id);
}
}
Merksatz: Der Anti-Corruption Layer schuetzt neuen Code vor Legacy-Chaos. Investieren Sie hier Zeit - es zahlt sich aus.
Datenbank-Migration
Die Datenbank ist oft der schwierigste Teil. Drei bewährte Strategien:
Strategie 1: Schema-Evolution (Online-Migration)
Aendern Sie das Schema schrittweise, ohne Downtime:
<?php
// Migration: Spalte hinzufuegen
class AddEmailToCustomers
{
public function up(PDO $db): void
{
// Schritt 1: Spalte hinzufuegen (NULL erlaubt)
$db->exec('ALTER TABLE customers ADD COLUMN email VARCHAR(255) NULL');
// Schritt 2: Daten migrieren (in Batches!)
$this->migrateData($db);
// Schritt 3: NOT NULL setzen (erst wenn alle Daten migriert)
// $db->exec('ALTER TABLE customers ALTER COLUMN email SET NOT NULL');
}
private function migrateData(PDO $db): void
{
$batchSize = 1000;
$lastId = 0;
do {
$stmt = $db->prepare(
'SELECT id, contact_info FROM customers
WHERE id > ? AND email IS NULL
ORDER BY id LIMIT ?'
);
$stmt->execute([$lastId, $batchSize]);
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
if (empty($rows)) {
break;
}
$updateStmt = $db->prepare(
'UPDATE customers SET email = ? WHERE id = ?'
);
foreach ($rows as $row) {
$email = $this->extractEmail($row['contact_info']);
$updateStmt->execute([$email, $row['id']]);
$lastId = $row['id'];
}
// Kurze Pause um DB nicht zu überlasten
usleep(100000); // 100ms
} while (count($rows) === $batchSize);
}
private function extractEmail(string $contactInfo): ?string
{
// Legacy: Email ist irgendwo im Freitext-Feld versteckt
if (preg_match('/[\w\.-]+@[\w\.-]+\.\w+/', $contactInfo, $matches)) {
return $matches[0];
}
return null;
}
}
Strategie 2: Dual-Write (Parallelbetrieb)
Schreiben Sie in beide Systeme, lesen Sie aus dem neuen:
<?php
class DualWriteCustomerRepository implements CustomerRepositoryInterface
{
public function __construct(
private readonly CustomerRepositoryInterface $legacy,
private readonly CustomerRepositoryInterface $modern,
private readonly LoggerInterface $logger,
private readonly bool $readFromModern = false
) {}
public function findById(int $id): ?Customer
{
if ($this->readFromModern) {
// Primaer aus Modern lesen
$customer = $this->modern->findById($id);
// Fallback auf Legacy wenn nicht gefunden
if ($customer === null) {
$customer = $this->legacy->findById($id);
// In Modern nachholen (Lazy Migration)
if ($customer !== null) {
$this->modern->save($customer);
}
}
return $customer;
}
return $this->legacy->findById($id);
}
public function save(Customer $customer): void
{
// IMMER in beide Systeme schreiben
$this->legacy->save($customer);
try {
$this->modern->save($customer);
} catch (\Throwable $e) {
// Modern-Fehler loggen aber nicht abbrechen
$this->logger->error('Dual-write to modern failed', [
'customer_id' => $customer->id,
'error' => $e->getMessage(),
]);
}
}
// Verifikation: Sind beide Systeme synchron?
public function verify(int $id): VerificationResult
{
$legacy = $this->legacy->findById($id);
$modern = $this->modern->findById($id);
if ($legacy === null && $modern === null) {
return new VerificationResult(true, 'Both null');
}
if ($legacy === null || $modern === null) {
return new VerificationResult(false, 'One is null');
}
// Felder vergleichen
$differences = [];
if ($legacy->email !== $modern->email) {
$differences['email'] = [
'legacy' => $legacy->email,
'modern' => $modern->email,
];
}
// Weitere Felder...
return new VerificationResult(
empty($differences),
empty($differences) ? 'Match' : json_encode($differences)
);
}
}
Strategie 3: Change Data Capture (CDC)
Aenderungen aus der Legacy-DB in Echtzeit erfassen:
<?php
class ChangeDataCapture
{
private int $lastProcessedId = 0;
public function __construct(
private readonly PDO $legacyDb,
private readonly PDO $modernDb,
private readonly LoggerInterface $logger
) {}
// Als Daemon oder Cron laufen lassen
public function sync(): void
{
$this->lastProcessedId = $this->getLastProcessedId();
while (true) {
$changes = $this->pollChanges();
foreach ($changes as $change) {
$this->processChange($change);
}
// Kurze Pause wenn keine Aenderungen
if (empty($changes)) {
sleep(1);
}
}
}
private function pollChanges(): array
{
// Variante A: Trigger-basierte Change-Tabelle
$stmt = $this->legacyDb->prepare(
'SELECT * FROM change_log
WHERE id > ?
ORDER BY id
LIMIT 100'
);
$stmt->execute([$this->lastProcessedId]);
return $stmt->fetchAll(PDO::FETCH_ASSOC);
}
private function processChange(array $change): void
{
try {
match ($change['operation']) {
'INSERT', 'UPDATE' => $this->upsertToModern($change),
'DELETE' => $this->deleteFromModern($change),
default => $this->logger->warning('Unknown operation', $change),
};
$this->lastProcessedId = $change['id'];
$this->saveLastProcessedId($change['id']);
} catch (\Throwable $e) {
$this->logger->error('CDC processing failed', [
'change_id' => $change['id'],
'error' => $e->getMessage(),
]);
// Nicht abbrechen - naechste Aenderung verarbeiten
}
}
// Whitelist erlaubter Tabellen - WICHTIG gegen SQL Injection!
private const ALLOWED_TABLES = [
'customers', 'orders', 'products', 'invoices'
];
private function upsertToModern(array $change): void
{
$tableName = $change['table_name'];
$recordId = $change['record_id'];
// Tabellennamen validieren (SQL Injection Prevention)
if (!in_array($tableName, self::ALLOWED_TABLES, true)) {
throw new \InvalidArgumentException("Unknown table: {$tableName}");
}
// Aktuellen Stand aus Legacy lesen
$stmt = $this->legacyDb->prepare(
"SELECT * FROM {$tableName} WHERE id = ?"
);
$stmt->execute([$recordId]);
$record = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$record) {
return;
}
// In Modern upserten
$columns = array_keys($record);
$placeholders = array_fill(0, count($columns), '?');
$sql = sprintf(
'INSERT INTO %s (%s) VALUES (%s)
ON CONFLICT (id) DO UPDATE SET %s',
$tableName,
implode(', ', $columns),
implode(', ', $placeholders),
implode(', ', array_map(fn($c) => "{$c} = EXCLUDED.{$c}", $columns))
);
$stmt = $this->modernDb->prepare($sql);
$stmt->execute(array_values($record));
}
private function deleteFromModern(array $change): void
{
$tableName = $change['table_name'];
// Gleiche Whitelist-Pruefung wie bei upsert!
if (!in_array($tableName, self::ALLOWED_TABLES, true)) {
throw new \InvalidArgumentException("Unknown table: {$tableName}");
}
$stmt = $this->modernDb->prepare(
"DELETE FROM {$tableName} WHERE id = ?"
);
$stmt->execute([$change['record_id']]);
}
// Checkpoint persistieren
private function getLastProcessedId(): int
{
$file = '/var/run/cdc_checkpoint.txt';
return file_exists($file) ? (int)file_get_contents($file) : 0;
}
private function saveLastProcessedId(int $id): void
{
file_put_contents('/var/run/cdc_checkpoint.txt', $id);
}
}
Datenbank-Migration Entscheidungshilfe:
Situation Empfehlung Kleine Aenderungen Schema-Evolution Komplettes Redesign Dual-Write + Lazy Migration Echtzeit-Sync nötig Change Data Capture Hohe Konsistenz-Anforderung Dual-Write mit Verifikation
Merksatz: Datenbank-Migrationen sind Marathons, keine Sprints. Planen Sie Wochen ein, nicht Tage.
Legacy-Code testen
Legacy-Code hat selten Tests. Bevor Sie refactoren, sichern Sie ab:
Characterization Tests
Tests, die das aktuelle Verhalten dokumentieren - nicht das erwartete:
<?php
class LegacyInvoiceCalculatorTest extends TestCase
{
private LegacyInvoiceCalculator $calculator;
protected function setUp(): void
{
$this->calculator = new LegacyInvoiceCalculator();
}
/**
* Characterization Test: Dokumentiert aktuelles Verhalten
* ACHTUNG: Dieses Verhalten koennte ein Bug sein!
*/
public function testCalculateTotal_WithDiscountAndTax(): void
{
$result = $this->calculator->calculateTotal(
items: [
['price' => 100, 'quantity' => 2],
['price' => 50, 'quantity' => 1],
],
discount: 10, // Prozent
taxRate: 19 // Prozent
);
// Aktuelles Verhalten: 267.75
// Erwartetes Verhalten: 267.30 (Rabatt vor Steuer)
// Wir dokumentieren das aktuelle Verhalten!
$this->assertEquals(267.75, $result);
}
/**
* Golden Master Test: Vergleich mit gespeichertem Output
*/
public function testGenerateReport_GoldenMaster(): void
{
$report = $this->calculator->generateReport(
month: 6,
year: 2025,
customerId: 123
);
$goldenMaster = file_get_contents(__DIR__ . '/fixtures/report_123_202506.json');
$this->assertJsonStringEqualsJsonString($goldenMaster, json_encode($report));
}
}
Approval Tests
Speichern Sie den aktuellen Output und vergleichen bei Aenderungen:
<?php
class ApprovalTestHelper
{
public static function verify(mixed $actual, string $testName): void
{
$actualFile = __DIR__ . "/approvals/{$testName}.received.txt";
$approvedFile = __DIR__ . "/approvals/{$testName}.approved.txt";
$actualContent = self::serialize($actual);
file_put_contents($actualFile, $actualContent);
if (!file_exists($approvedFile)) {
throw new \RuntimeException(
"No approved file. Review and rename:\n" .
"mv {$actualFile} {$approvedFile}"
);
}
$approvedContent = file_get_contents($approvedFile);
if ($actualContent !== $approvedContent) {
// HINWEIS: shell_exec("diff") ist nur für lokale Entwicklung!
// In CI: Entweder PHP-Library (sebastian/diff) nutzen,
// oder CI-Runner absichern (kein Schreibzugriff auf kritische Pfade).
throw new \RuntimeException(
"Output changed! Diff:\n" .
shell_exec("diff {$approvedFile} {$actualFile}")
);
}
// Cleanup
unlink($actualFile);
}
private static function serialize(mixed $value): string
{
if (is_array($value) || is_object($value)) {
return json_encode($value, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);
}
return (string)$value;
}
}
// Verwendung im Test
public function testLegacyExport(): void
{
$exporter = new LegacyExporter();
$result = $exporter->export(customerId: 123);
ApprovalTestHelper::verify($result, 'legacy_export_customer_123');
}
Test-Coverage für kritische Pfade
<?php
class CriticalPathIdentifier
{
public function __construct(
private readonly PDO $db
) {}
/**
* Identifiziert die meistgenutzten Code-Pfade basierend auf
* Access-Logs oder Feature-Usage-Tracking
*/
public function findCriticalPaths(): array
{
$stmt = $this->db->query(
"SELECT
endpoint,
COUNT(*) as calls,
AVG(duration_ms) as avg_duration,
COUNT(*) FILTER (WHERE status >= 500) as errors
FROM access_logs
WHERE created_at > NOW() - INTERVAL '30 days'
GROUP BY endpoint
ORDER BY calls DESC
LIMIT 50"
);
return $stmt->fetchAll(PDO::FETCH_ASSOC);
}
/**
* Generiert Test-Skelette für kritische Pfade
*/
public function generateTestSkeletons(array $criticalPaths): string
{
$output = "<?php\n\nclass CriticalPathTests extends TestCase\n{\n";
foreach ($criticalPaths as $path) {
$methodName = $this->pathToMethodName($path['endpoint']);
$output .= <<<PHP
/**
* Endpoint: {$path['endpoint']}
* Calls/Month: {$path['calls']}
* Avg Duration: {$path['avg_duration']}ms
* Error Rate: {$this->calculateErrorRate($path)}%
*/
public function test_{$methodName}(): void
{
\$this->markTestIncomplete('TODO: Implement critical path test');
}
PHP;
}
$output .= "}\n";
return $output;
}
private function pathToMethodName(string $path): string
{
return preg_replace('/[^a-z0-9]+/i', '_', trim($path, '/'));
}
private function calculateErrorRate(array $path): float
{
return round($path['errors'] / $path['calls'] * 100, 2);
}
}
Merksatz: Ohne Tests ist Refactoring Gluecksspiel. Characterization Tests sind Ihr Sicherheitsnetz.
UI-Migration: Schrittweise modernisieren
Micro-Frontends für graduelle Migration
<!-- Legacy-Seite mit eingebettetem Modern-Widget -->
<!DOCTYPE html>
<html>
<head>
<title>Legacy Application</title>
<!-- Legacy-CSS -->
<link rel="stylesheet" href="/legacy/styles.css">
</head>
<body>
<div id="legacy-header">
<!-- Alter Header bleibt -->
</div>
<div id="legacy-content">
<!-- Alter Content -->
</div>
<!-- Modern Widget einbetten -->
<div id="modern-dashboard-widget"></div>
<script src="/legacy/jquery.min.js"></script>
<script src="/legacy/app.js"></script>
<!-- Modern Widget laden (isoliert) -->
<script type="module">
import { mountDashboardWidget } from '/modern/widgets/dashboard.js';
mountDashboardWidget(document.getElementById('modern-dashboard-widget'), {
apiBase: '/api/v2',
userId: window.LEGACY_USER_ID
});
</script>
</body>
</html>
Shadow DOM für CSS-Isolation
// modern/widgets/dashboard.js
export function mountDashboardWidget(container, config) {
// Shadow DOM schuetzt vor Legacy-CSS-Konflikten
const shadow = container.attachShadow({ mode: 'open' });
shadow.innerHTML = `
<style>
/* Eigene Styles - isoliert von Legacy */
:host {
display: block;
font-family: system-ui, sans-serif;
}
.dashboard {
padding: 1rem;
background: #f5f5f5;
border-radius: 8px;
}
/* ... */
</style>
<div class="dashboard">
<h2>Neues Dashboard</h2>
<div id="content">Laden...</div>
</div>
`;
loadDashboardData(shadow.querySelector('#content'), config);
}
async function loadDashboardData(container, config) {
const response = await fetch(`${config.apiBase}/dashboard/${config.userId}`);
const data = await response.json();
container.innerHTML = renderDashboard(data);
}
Server-Side Includes (SSI) für PHP
<?php
// legacy/templates/page.php
?>
<!DOCTYPE html>
<html>
<head>
<title><?= htmlspecialchars($pageTitle) ?></title>
</head>
<body>
<?php include 'header.php'; ?>
<main>
<?php if ($this->shouldShowModernWidget('sidebar')): ?>
<!-- Modern Widget via Server-Side Include -->
<?= $this->renderModernWidget('sidebar', [
'user_id' => $currentUser->id,
'context' => 'dashboard'
]) ?>
<?php else: ?>
<!-- Legacy Sidebar -->
<?php include 'legacy_sidebar.php'; ?>
<?php endif; ?>
<div class="content">
<?= $content ?>
</div>
</main>
<?php include 'footer.php'; ?>
</body>
</html>
<?php
// ModernWidgetRenderer.php
class ModernWidgetRenderer
{
public function __construct(
private readonly string $widgetServiceUrl,
private readonly int $timeout = 2
) {}
public function render(string $widgetName, array $props): string
{
$cacheKey = "widget:{$widgetName}:" . md5(json_encode($props));
// Cache pruefen (Widgets ändern sich selten)
if ($cached = $this->cache->get($cacheKey)) {
return $cached;
}
try {
$html = $this->fetchWidget($widgetName, $props);
$this->cache->set($cacheKey, $html, ttl: 300);
return $html;
} catch (\Throwable $e) {
// Bei Fehler: Fallback oder leer
return "<!-- Widget {$widgetName} unavailable -->";
}
}
private function fetchWidget(string $widgetName, array $props): string
{
$ch = curl_init($this->widgetServiceUrl . "/widgets/{$widgetName}");
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => $this->timeout,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode($props),
CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
]);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($httpCode !== 200) {
throw new \RuntimeException("Widget fetch failed: {$httpCode}");
}
return $response;
}
}
Feature Flags für sichere Rollouts
<?php
interface FeatureFlagService
{
public function isEnabled(string $flag, array $context = []): bool;
}
class DatabaseFeatureFlagService implements FeatureFlagService
{
public function __construct(
private readonly PDO $db,
private readonly CacheInterface $cache
) {}
public function isEnabled(string $flag, array $context = []): bool
{
$config = $this->getConfig($flag);
if ($config === null) {
return false; // Unknown flag = off
}
if (!$config['enabled']) {
return false;
}
// Globales Kill-Switch
if ($config['kill_switch']) {
return false;
}
// Percentage Rollout
if ($percentage = $config['percentage']) {
$userId = $context['user_id'] ?? 0;
// Deterministisch basierend auf User-ID
$bucket = crc32($flag . ':' . $userId) % 100;
if ($bucket >= $percentage) {
return false;
}
}
// User-Whitelist
if ($whitelist = $config['user_whitelist']) {
$userId = $context['user_id'] ?? null;
if ($userId && !in_array($userId, $whitelist)) {
return false;
}
}
// Zeit-basierte Aktivierung
if ($startAt = $config['start_at']) {
if (new \DateTime() < new \DateTime($startAt)) {
return false;
}
}
return true;
}
private function getConfig(string $flag): ?array
{
$cacheKey = "feature_flag:{$flag}";
if ($cached = $this->cache->get($cacheKey)) {
return $cached;
}
$stmt = $this->db->prepare(
'SELECT * FROM feature_flags WHERE name = ?'
);
$stmt->execute([$flag]);
$config = $stmt->fetch(PDO::FETCH_ASSOC) ?: null;
if ($config) {
$config['user_whitelist'] = json_decode($config['user_whitelist'] ?? '[]', true);
}
$this->cache->set($cacheKey, $config, ttl: 60);
return $config;
}
}
// Admin-Controller für Feature Flags
class FeatureFlagController
{
public function updatePercentage(string $flag, int $percentage): void
{
if ($percentage < 0 || $percentage > 100) {
throw new \InvalidArgumentException('Percentage must be 0-100');
}
$stmt = $this->db->prepare(
'UPDATE feature_flags SET percentage = ?, updated_at = NOW() WHERE name = ?'
);
$stmt->execute([$percentage, $flag]);
$this->cache->delete("feature_flag:{$flag}");
$this->logger->info("Feature flag percentage updated", [
'flag' => $flag,
'percentage' => $percentage,
]);
}
public function emergencyKill(string $flag): void
{
$stmt = $this->db->prepare(
'UPDATE feature_flags SET kill_switch = true, updated_at = NOW() WHERE name = ?'
);
$stmt->execute([$flag]);
$this->cache->delete("feature_flag:{$flag}");
$this->logger->warning("EMERGENCY: Feature flag killed", ['flag' => $flag]);
}
}
Migrations-Checkliste
Vor dem Start:
- Aktuelle System-Dokumentation erstellen (auch wenn lückenhaft)
- Kritische Geschaeftsprozesse identifizieren
- Stakeholder-Buy-In sichern (wird länger dauern als gedacht)
- Monitoring/Alerting für Legacy UND neues System
- Rollback-Plan für jede Phase
Während der Migration:
- Strangler Fig Pattern: Proxy vorschalten
- Anti-Corruption Layer für jede Legacy-Schnittstelle
- Feature Flags für graduelle Rollouts
- Dual-Write für Datenbank (wenn möglich)
- Characterization Tests vor jedem Refactoring
- Keine neuen Features im Legacy (Feature Freeze)
Nach der Migration:
- Legacy-System abschalten (nicht vergessen!)
- Datenbank-Sync beenden
- Proxy entfernen
- Feature Flags aufraeumen (Expiry-Date setzen, sonst verrottet das System!)
- Lessons Learned dokumentieren
Typische Fehler vermeiden
| Fehler | Besser |
|---|---|
| Big Bang Rewrite | Strangler Fig Pattern |
| Alles auf einmal migrieren | Feature für Feature |
| Keine Tests vor Refactoring | Characterization Tests zuerst |
| Legacy-Logik 1:1 kopieren | Anforderungen neu verstehen |
| Zu frueh optimieren | Erst funktionieren, dann schnell |
| Dual-Write ohne Verifikation | Regelmaessig Daten vergleichen |
| Feature Flags nie aufraeumen | Expiry-Date setzen |
Fazit
Legacy-Modernisierung ist kein Projekt, sondern ein Prozess. Die wichtigsten Erkenntnisse:
- Strangler Fig: Schrittweise ersetzen, nicht Big Bang
- Anti-Corruption Layer: Legacy-Chaos vom neuen Code isolieren
- Characterization Tests: Verhalten dokumentieren vor Refactoring
- Feature Flags: Graduelle Rollouts mit Notfall-Kill-Switch
- Dual-Write: Parallelbetrieb mit Verifikation
Ü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