Ein Altsystem lässt sich modernisieren, ohne es neu zu bauen
Legacy Code entsteht überall dort, wo eine PHP-Anwendung über Jahre gewachsen ist und niemand mehr sagen kann, was eine Änderung an anderer Stelle auslöst. Der naheliegende Reflex ist die Neuentwicklung — und genau die scheitert am häufigsten, weil das alte System weiterläuft und weiter Anforderungen bekommt, während das neue entsteht. Dieser Ratgeber zeigt die Schrittfolge, mit der Sie ein Altsystem ohne Rewrite beherrschbar machen: woran gewachsene Systeme kranken, belegt am Quellcode eines quelloffenen Systems, und wie ein neuer Teil daneben wächst.
⚡ Das Wichtigste in Kürze
- Legacy heißt nicht alt: Ob eine Anwendung Legacy Code enthält, entscheidet die fehlende Testbarkeit und nicht das Erscheinungsjahr.
- Der Rewrite ist die aufwendigste Antwort: Bis zur Umstellung werden zwei Systeme gepflegt.
- Modularer Monolith: Das Neue wächst neben dem Alten, in derselben Anwendung und auf derselben Datenbank.
- Neue Features nur im neuen Teil: Der Altbestand wächst nicht weiter.
- Nicht alles wird ersetzt: Was beim Umbau eines Legacy-Systems stehen bleibt, ist der Normalfall und kein Scheitern.
Was ist Legacy Code, und warum trifft es PHP besonders?
Legacy Code ist Quellcode, der produktiv läuft, aber sich nicht mehr gefahrlos ändern lässt, weil automatisierte Tests fehlen. Entscheidend ist nicht das Alter der Software, sondern das fehlende Sicherheitsnetz. Für Entwickler heißt das: Jede Änderung bleibt eine Vermutung, bis sie im laufenden Betrieb auffällt oder eben nicht.
Michael Feathers hat diese Sicht in seinem Buch Working Effectively with Legacy Code (2004) in einen Satz gefasst: „Code ohne Tests ist schlechter Code.“ Das klingt hart, trifft aber den Kern. Ein Programm, dessen Verhalten sich nur durch Ausprobieren feststellen lässt, zwingt bei jeder Änderung zur Handarbeit. Und weil diese Prüfung Zeit beansprucht, wird sie irgendwann verkürzt — genau dann entsteht der Zustand, den man später Legacy Code nennt.
PHP-Anwendungen sind davon überdurchschnittlich oft betroffen, und dafür gibt es drei nachvollziehbare Gründe. Die Sprache ist seit den späten Neunzigern im Einsatz, entsprechend viele Systeme laufen seit über einem Jahrzehnt im Produktivbetrieb. Viele davon entstanden vor Composer, dem heutigen Standardwerkzeug zur Verwaltung fremder Programmbibliotheken — damals wurde solcher Code in den eigenen Projektordner kopiert und dort verändert, womit er von jedem Update abgeschnitten war. Und PHP erlaubt es bis heute, Programmlogik direkt in der Ausgabeschicht auszuführen.
In diesem Ratgeber geht es um die Codebasis selbst: um die Frage, wo Sie ansetzen und in welcher Reihenfolge.
Woran erkennen Sie ein gewachsenes PHP-System? Drei Symptome im Template
Drei Symptome zeigen ein gewachsenes PHP-System zuverlässig an. Das Template entscheidet und rechnet, statt nur auszugeben. Die Herkunft einer Variablen lässt sich nicht mehr in einer einzigen Datei nachvollziehen. Und es fehlt ein Aufruf mit definierter Eingabe und Rückgabe, an dem ein Test ansetzen könnte.
Am deutlichsten zeigen sich die Symptome von Legacy Code im Template: Dort laufen Datenbeschaffung und Darstellung zusammen, und in gewachsenen Systemen verschmelzen beide zu einer Schicht. Moderne Frameworks halten das getrennt, ältere Systeme hatten die Trennung nie.
Ich zeige das an vtiger CRM, einer quelloffenen Anwendung auf Basis von PHP und Smarty. Ich habe mit vtiger-Installationen gearbeitet und genau den Umbau gemacht, um den es in diesem Ratgeber geht. Dass der Code offenliegt, ist der Zusatznutzen: Smarty ist eine Template Engine, also eine Bibliothek, die Vorlagen mit Daten füllt und daraus HTML erzeugt; die Vorlagen liegen als .tpl-Dateien im Projekt, die Syntax beschreibt die Smarty-Dokumentation. Jede Stelle, die ich zeige, können Sie selbst nachschlagen. Aktuell ist die Reihe bei Version 8.4.0 auf PHP 8; die hier gezeigten Stellen stammen aus der über GitHub zeilengenau einsehbaren Version 7.4.0.
Lesbarkeit: Logik im Template, Template in der Logik
Ein Template soll ausgeben. In Legacy Code entscheidet es zusätzlich, rechnet und formatiert:
{if $CURRENCY_SYMBOL_PLACEMENT eq '1.0$'}
{$LISTVIEW_ENTRY_VALUE}{$LISTVIEW_ENTRY->get('currencySymbol')}
{else}
{$LISTVIEW_ENTRY->get('currencySymbol')}{$LISTVIEW_ENTRY_VALUE}
{/if}
Die Vorlage entscheidet hier, ob das Währungszeichen vor oder hinter dem Betrag steht. Wer diese Darstellung ändern will, ändert eine Datei, die eigentlich nur ausgeben sollte. Das Beispiel stammt aus dem öffentlichen Quellcode von vtiger CRM, Version 7.4.0 (layouts/v7/modules/Vtiger/ListViewContents.tpl, Zeile 173, abgerufen am 16.08.2026). In dieser einen Vorlage stehen 44 {if}-Bedingungen, 27 {assign}-Zuweisungen und 5 {foreach}-Schleifen — auf 257 Zeilen.
Weiter oben ruft dieselbe Datei zwei statische PHP-Klassen direkt auf:
<input type="hidden" name="currentSearchParams"
value="{Vtiger_Util_Helper::toSafeHTML(Zend_JSON::encode($SEARCH_DETAILS))}"
id="currentSearchParams" />
Damit ist die Grenze zwischen Darstellung und Logik aufgehoben. In einer nach MVC getrennten Anwendung stünde dieser Aufruf im Controller, nicht in der Vorlage.
Datenherkunft: Woher kommt die Variable im Template?
Die Variable steht im Template, zugewiesen wird sie an ganz anderer Stelle. Wer dort einen Fehler sucht, muss bei jeder Änderung neu beantworten, woher ein Wert stammt:
$viewer->assign('VIEW', $request->get('view'));
$viewer->assign('MODULE_MODEL', $moduleModel);
$viewer->assign('RECORD_ACTIONS', $this->getRecordActionsFromModule($moduleModel));
$viewer->assign('CURRENT_USER_MODEL', Users_Record_Model::getCurrentUserModel());
$viewer->assign('MODULE', $moduleName);
$viewer->assign('PAGING_MODEL', $pagingModel);
Diese sechs Zeilen sind ein Ausschnitt. In modules/Vtiger/views/List.php stehen 50 solcher Zuweisungen, verteilt über vier Methoden: preProcess(), process(), initializeListViewContents() und assignCustomViews(). Der Wert MODULE_MODEL wird an zwei verschiedenen Stellen gesetzt. Das Template selbst definiert keine einzige dieser Variablen, es benutzt sie nur. Wer beim Lesen wissen will, woher ein Wert stammt, muss vier Methoden in einer anderen Datei durchsuchen — und dabei die Reihenfolge kennen, in der sie aufgerufen werden.
Testbarkeit: Es gibt nichts, worauf ein Test ansetzen könnte
Ein Unit-Test braucht einen Ansatzpunkt: einen Aufruf mit definierter Eingabe und einen Rückgabewert, den er prüfen kann. Genau den gibt es hier nicht. Die Methode process() beschafft die Daten und rendert im selben Zug — sie endet mit $viewer->view('ListViewContents.tpl', $moduleName); und gibt nichts zurück. Der quelloffene Code zeigt es deutlich:
vimport ('~/libraries/Smarty/libs/SmartyBC.class.php');
class Vtiger_Viewer extends SmartyBC {
Die Ausgabeschicht ist die Template Engine selbst: Die Viewer-Klasse erbt von SmartyBC, der Rückwärtskompatibilitätsklasse von Smarty, und damit über eine Stufe von Smarty selbst. Das ist im öffentlichen Quellcode nachvollziehbar (includes/runtime/Viewer.php, Zeile 11 und 13). Ein Test müsste die halbe Anwendung starten, um eine einzige Formatierungsregel zu prüfen. Genau daraus entsteht die Ausgangsfrage dieses Ratgebers: Tests lassen sich nicht nachrüsten, solange es keine Naht gibt, an der man ansetzen kann.
Das Muster ist nicht auf ein einzelnes System beschränkt: Auch die modified eCommerce Shopsoftware und PrestaShop setzen bis heute auf Smarty. PrestaShop führt Smarty bis heute als Abhängigkeit in seiner Paketkonfiguration. Und modified zeigt denselben Aufbau: verstreute assign()-Aufrufe im Controller, am Ende ein display() auf eine Vorlagendatei. Auch damit habe ich gearbeitet. Die Symptome sind übertragbar, die Reihenfolge beim Umbau ebenfalls.
📌 Der Test: Woher kommt diese Variable?
- Können Sie die Zuweisung finden, ohne das ganze Projekt zu durchsuchen?
- Gibt es genau eine Stelle, an der sie gesetzt wird — oder mehrere?
- Ändert sich der Wert je nachdem, in welcher Reihenfolge die Methoden laufen?
Lautet eine der Antworten „nein“ oder „mehrere“, haben Sie das Symptom vor sich.
Warum scheitert der große Rewrite so oft?
Der große Rewrite scheitert, weil er gegen ein bewegliches Ziel antritt. Das Altsystem bekommt weiter Anforderungen, während das neue entsteht. Niemand kennt den vollen Funktionsumfang, denn er steht im Code und nicht in einer Spezifikation. Und bis zur Umstellung werden zwei Systeme gepflegt statt eines.
Der erste Reflex vor einer großen Menge Legacy Code ist fast immer derselbe: alles neu bauen. Der Gedanke ist verständlich, denn er verspricht ein Ende des Ärgers statt einer Verwaltung davon. In der Praxis geht er aus drei Gründen selten auf.
Am unauffälligsten ist dabei der zweite Punkt. Das Wissen über den Funktionsumfang steckt im Legacy Code, nicht in einer Spezifikation — und am wenigsten sichtbar sind die Prozesse, die im Hintergrund laufen: Cronjobs, die nachts Daten abgleichen, oder Queues, die Aufgaben abarbeiten. Sie fallen erst auf, wenn sie fehlen. Und bis zur Umstellung fällt jede Anforderung doppelt an: einmal im alten Teil, damit der Betrieb weiterläuft, einmal im neuen, damit er bei der Einführung nicht schon veraltet ist.
Was ist ein modularer Monolith, und warum passt er zu Altsystemen?
Ein modularer Monolith ist eine Anwendung, die als eine Einheit ausgeliefert wird, intern aber aus klar abgegrenzten Modulen mit definierten Schnittstellen besteht. Anders als bei Microservices gibt es kein Netzwerk zwischen den Teilen, kein eigenes Deployment je Modul und keine getrennte Datenhaltung. Die Grenze verläuft im Code.
Die Abgrenzung geht in zwei Richtungen. Nach oben: Ein modularer Monolith ist kein verteiltes System. Es gibt keinen Netzwerkaufruf zwischen zwei Modulen, kein eigenes Deployment je Teil und keinen Betriebsaufwand für Dienste, die sich gegenseitig finden müssen. Nach unten: Er ist auch kein ungeteilter Block. Ein klassischer Monolith hat gar keine inneren Grenzen — hier gibt es sie, sie sind nur nicht als Prozessgrenze ausgeführt.
Für ein Altsystem passt das genau. Laravel kommt als eigenes Modul daneben, mit eigenem Einstiegspunkt, aber auf derselben Datenbank. Das Frontend des neuen Teils entsteht mit Vue.js, das Backend in Laravel. Der alte Teil bemerkt davon nichts: Er läuft weiter wie zuvor, auf demselben Server, mit demselben Datenbestand. Es gibt keinen Stichtag, an dem umgeschaltet wird, und der Legacy Code läuft unverändert weiter.
Strangler Pattern: Wie ersetzen Sie das Altsystem Stück für Stück?
Das Strangler Pattern stellt eine Weiche vor beide Systeme. Diese Weiche prüft bei jeder Anfrage den angefragten Pfad und reicht ihn an den neuen Teil weiter, sofern er dort schon abgedeckt ist, sonst an das Altsystem. Jeder übernommene Bereich wird zu einem Eintrag darin und bleibt einzeln herausnehmbar.
Der Name kommt aus der Botanik. Eine Würgefeige wächst um einen Baum herum, bis sie ihn vollständig umschlossen hat: Der Baum vergeht, die Feige steht. Technisch beschreibt das Strangler Pattern genau diesen Ablauf. Eine Schicht davor entscheidet bei jeder Anfrage, wer sie bearbeitet.
In PHP ist diese Schicht überraschend klein. Ein einziger Einstiegspunkt prüft den angefragten Pfad und reicht ihn an das neue Modul oder an das Altsystem weiter:
<?php
$pfad = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
$neueBereiche = ['/kunden', '/berichte'];
foreach ($neueBereiche as $bereich) {
if (str_starts_with($pfad, $bereich)) {
require __DIR__ . '/neu/public/index.php';
exit;
}
}
require __DIR__ . '/alt/index.php';
Mehr braucht es zu Beginn nicht. Wandert ein weiterer Bereich in den neuen Teil, kommt ein Eintrag in die Liste. Geht dabei etwas schief, nehmen Sie den Eintrag wieder heraus — die alte Version läuft ja noch. Die Weiche muss nicht in PHP liegen; sie kann auch als Rewrite-Regel im Webserver stehen.
Genau darin liegt der Unterschied zum Rewrite: Der Legacy Code läuft die ganze Zeit weiter, und jeder Schritt ist einzeln umkehrbar. Refactoring wird damit zu einer Folge kleiner, jederzeit anhaltbarer Eingriffe statt zu einem Vorhaben mit einem einzigen großen Umschalttermin. Das Muster setzt keine bestimmte Technik voraus, sondern nur eine Stelle, an der die Weiche steht.
Wie gehen Sie vor? Fünf Schritte zum modularen Monolithen
Der Umbau folgt fünf Schritten, die aufeinander aufbauen: das Altsystem einfrieren, Laravel als eigenes Modul danebenstellen, neue Funktionen nur noch dort bauen, alte Logik bei Gelegenheit herüberziehen und den Rest bewusst stehen lassen. Die ersten drei ändern nichts am laufenden Betrieb.
Aus dem Muster wird ein Vorgehen. Erst ab Schritt vier wird Altbestand angefasst.
- Das Altsystem einfrieren: Ab jetzt keine Umbauten, keine Aufräumarbeiten, keine neuen Funktionen im alten Teil — nur noch Fehlerbehebung und Sicherheitsupdates. Wer gleichzeitig aufräumt und erweitert, hat am Ende beides halb fertig. Sinnvoll ist hier eine Umgebung, in der das Altsystem in einem Container reproduzierbar startet.
- Laravel als eigenes Modul daneben stellen: Gleiche Anwendung, gleiche MySQL-Datenbank, eigener Einstiegspunkt. Vue.js übernimmt das Frontend des neuen Teils. Das Altsystem merkt davon nichts.
- Neue Features ausschließlich im neuen Modul bauen: Ab diesem Tag wächst der Legacy Code nicht weiter. Das ist der Schritt mit der größten Wirkung und dem geringsten Risiko, denn er ändert nichts an dem, was läuft. Ich halte ihn für den wichtigsten der fünf — und für den, an dem die meisten Vorhaben scheitern. Nicht an der Technik, sondern an der Disziplin: Es gibt immer eine Anforderung, die im alten Teil schneller ginge.
- Alte Logik nach und nach herüberziehen: Immer dann, wenn ein Bereich ohnehin angefasst werden muss. Nicht als eigenes Projekt, sondern als Nebenprodukt der laufenden Arbeit. Ein Bereich pro Schritt, jeder einzeln auslieferbar und über die Versionskontrolle zurückholbar. Der Datenzugriff wandert dabei auf ein ORM (Object Relational Mapping), der neue Teil bekommt von Anfang an Tests.
- Den Rest bewusst stehen lassen: Legacy Code, der selten angefasst wird und funktioniert, bleibt. Was am Ende stehen bleibt, ist kein aufgeschobener Rest, sondern eine Entscheidung: Ein Altsystem vollständig abzulösen ist die Ausnahme, nicht das Ziel des Vorgehens.
✓ Checkliste: Bevor Sie das erste Modul bauen
- → Läuft das Altsystem auf einer PHP-Version, die noch Sicherheitsupdates erhält?
- → Lässt sich der gesamte Bestand reproduzierbar auf einem Entwicklungsrechner starten?
- → Liegt jede Datei des Altsystems in der Versionskontrolle?
- → Wissen Sie, welche Hintergrundprozesse laufen und was bei ihrem Ausfall passiert?
- → Steht fest, welcher Bereich als erster in den neuen Teil wandert?
- → Gibt es eine Stelle, an der die Weiche sitzen kann?
Wo lohnt sich der Umbau, und was bleibt einfach stehen?
Umgebaut wird, was oft angefasst wird, wiederkehrend Fehler produziert oder im Ausfall die meiste Nacharbeit verursacht. Alles andere bleibt stehen. Über die Reihenfolge entscheiden diese drei Kriterien — und nicht die Frage, welcher Teil der Anwendung sich am unangenehmsten liest. Der hässlichste Code ist selten der, der im Alltag am meisten aufhält.
Nicht jede Stelle im Legacy Code verdient einen Umbau. Drei Kriterien haben sich für mich bewährt, und sie stehen bewusst in dieser Reihenfolge.
Änderungshäufigkeit. Was oft angefasst wird, lohnt den Umbau zuerst, denn dort wirkt jede Verbesserung mehrfach. Code, der seit Jahren unverändert liegt, bringt diesen Effekt nicht.
Fehlerdichte. Wo dieselben Fehler wiederkehren, fehlt Struktur und nicht Sorgfalt. Ein Bereich, der regelmäßig nachgebessert werden muss, ist ein Kandidat für Code Refactoring.
Risiko bei Ausfall. Was im Fehlerfall am meisten Vertrauen beschädigt und die meiste Nacharbeit verursacht, sichern Sie zuerst ab. Datenbanknahe Bereiche fallen dabei oft zuerst auf, weil sich dort Fehler und Trägheit überlagern.
Der Gegentest ist genauso wichtig. Was selten angefasst wird, stabil läuft und niemanden aufhält, bleibt — auch wenn der Code hässlich ist. Nicht jede Zeile Legacy Code muss verschwinden. Code Refactoring folgt der laufenden Arbeit, es ist kein Selbstzweck. Ein Bereich, den seit Jahren niemand geöffnet hat, wird durch einen Umbau nicht besser, sondern nur neu.
Welche Stellen sich zuerst lohnen, ist eine eigene Frage — hier geht es darum, wie der Umbau technisch abläuft, wenn die Entscheidung gefallen ist.
Muss ich vorher die PHP-Version aktualisieren?
Ja, und zwar bevor irgendetwas anderes passiert. Eine PHP-Version ohne Sicherheitsupdates entwertet jedes weitere Vorhaben: Sie bauen ein neues Modul auf ein Fundament, für das niemand mehr Lücken schließt. Der Versionssprung steht deshalb vor dem ersten Modul, nicht danach — auch dann, wenn er unbequem ist und niemand ihn bezahlen will.
Der Stand laut der offiziellen Übersicht der unterstützten PHP-Versionen, abgerufen am 16. August 2026: Sicherheitsupdates erhalten die Zweige 8.2 bis 8.5. Für 8.2 laufen sie zum 31. Dezember 2026 aus, für 8.3 ein Jahr später, für 8.4 und 8.5 entsprechend darüber hinaus. PHP 8.0, 8.1 und alle Zweige bis einschließlich 7.4 bekommen keine Sicherheitsupdates mehr. Wenn Ihr Legacy Code dort läuft, ist die PHP-Version aktualisieren der erste Schritt und nicht der letzte.
Im modularen Monolithen fällt der Sprung leichter. Das neue Modul setzt eine aktuelle Version voraus und zieht den alten Teil mit: Sie aktualisieren einmal die Laufzeitumgebung und arbeiten danach nur die Stellen ab, an denen das Altsystem tatsächlich anstößt.
Daraus folgt etwas, das in keinem Leitfaden steht, weil es zwei Regeln gegeneinander rechnet. Schritt eins verlangt, das Altsystem einzufrieren. Die Supportdaten sagen, wie lange Sie sich das leisten können. Wer heute einfriert und auf PHP 8.2 läuft, hat bis zum 31. Dezember 2026 Zeit — danach ist das eingefrorene System nicht mehr nur unbeweglich, sondern ungeschützt. Das Einfrieren hat also ein Ablaufdatum, und es steht nicht in Ihrem Projektplan, sondern im Releasekalender von PHP. Wer den Versionssprung hinter den ersten Umbau schiebt, verliert genau die Ruhe, für die er eingefroren hat.
💡 Praxis-Tipp: Erst die Version, dann alles andere
Die Reihenfolge ist nicht verhandelbar: Ein Modul auf einer nicht mehr gepflegten Laufzeitumgebung erbt deren Probleme vom ersten Tag an — und der Versionssprung steht danach trotzdem noch aus.
Häufig gestellte Fragen
Über den Autor
📌 Hinweis
Die gezeigten Codestellen stammen aus quelloffenen Projekten und dienen der fachlichen Veranschaulichung. vtiger CRM steht unter der Vtiger Public License, Smarty unter der LGPL-3.0. Genannte Produktnamen sind Marken der jeweiligen Inhaber. Es besteht keine Verbindung zu den genannten Projekten.
Ein gewachsenes System, das niemand mehr anfassen will?
Ich schaue mir die Codebasis an, benenne die Stellen, an denen ein Umbau tatsächlich trägt, und baue die neue Schicht so daneben, dass Ihr laufendes System dabei möglichst unangetastet bleibt. Mehr dazu unter individuelle Webanwendungen mit Laravel.