Alle Artikel
12. September 2026
8 mins

Rector im Altprojekt

Von Tim Rutte, Cloud & Software ArchitectThemaLegacy & Modernisierung

Ein blauer Stempel steht auf einem Stapel Papier; nur das oberste Blatt trägt einen Abdruck.

Rector wird meistens auf dieselbe Art eingeführt und scheitert meistens aus demselben Grund. Jemand liest, dass es PHP-Code automatisch modernisiert, richtet es ein, wählt das größte Regelset, lässt es über das ganze Projekt laufen und steht danach vor viertausend geänderten Dateien.

Der Rest ist absehbar. Niemand kann das prüfen, die Testsuite ist rot, die Ursache liegt irgendwo in viertausend Dateien, und nach zwei Tagen wird alles verworfen. Danach gilt im Team: „Rector haben wir probiert, das funktioniert bei uns nicht."

Dabei war das Werkzeug nie das Problem. Das Problem war der Zuschnitt. Dieser Artikel beschreibt, wie Rector in einem gewachsenen Projekt tatsächlich nützt, welche Regeln sicher sind und welche Meinungen sind, und wo die Grenze verläuft.

Was es ist, und was es nicht ist

Rector liest PHP-Code als Baum, wendet Regeln darauf an und schreibt ihn zurück. Eine Regel ist dabei eine sehr konkrete Sache: „Ersetze diesen Aufruf durch jenen", „setze diese Typangabe", „wandle diese Schleife in diese Form".

Daraus folgt beides, das Können und die Grenze. Rector ist unschlagbar bei Änderungen, die tausendfach gleich sind und deren richtige Form aus dem Code allein hervorgeht. Es ist blind für alles, was Bedeutung braucht: Ob diese Methode noch gebraucht wird, ob diese Sonderbehandlung eine Vereinbarung mit einem Kunden ist, ob dieser Name noch stimmt.

Es ist damit das genaue Gegenstück zu einem Coding-Agenten. Der Agent versteht Absicht und ist nicht deterministisch; Rector versteht keine Absicht und liefert bei hundert Läufen hundertmal dasselbe Ergebnis. Für eine mechanische Änderung an tausend Stellen ist die zweite Eigenschaft die wertvollere: Was deterministisch ist, muss man nicht an tausend Stellen einzeln prüfen.

Ein Regelset, ein Verzeichnis, ein Änderungsantrag

Der Einstieg, der trägt, ist die Umkehrung des üblichen: so wenig wie möglich auf einmal.

<?php
// rector.php: der erste Lauf. Bewusst eng.
return RectorConfig::configure()
    // Ein Verzeichnis, nicht das Projekt. Am besten eines,
    // das Tests hat und das jemand im Team kennt.
    ->withPaths([__DIR__ . '/src/Bestellung'])

    // Ein Sprung, nicht alle. Von der Fassung, auf der das
    // Projekt steht, zur naechsten.
    ->withPhpSets(php82: true)

    // Nichts weiter. Keine Aufraeumregeln, keine Qualitaetssets.
    // Die kommen spaeter und einzeln.
    ->withImportNames(importShortClasses: false);

Der erste Lauf ist eine Vorschau, kein Schreibvorgang, und schon die Zahl darin ist eine Information:

# --dry-run schreibt nichts und zeigt jede Aenderung als Diff.
vendor/bin/rector process --dry-run

# Danach: schreiben, Tests, ansehen, ein Antrag. In dieser
# Reihenfolge, und nichts anderes im selben Antrag.
vendor/bin/rector process
vendor/bin/phpunit
git switch -c rector/php82-bestellung

Der letzte Punkt ist der wichtigste und wird am häufigsten verletzt: In einem Rector-Antrag steht ausschließlich, was Rector geschrieben hat. Keine Handarbeit, keine „während ich schon mal dabei war". Der ganze Nutzen für die Durchsicht hängt daran, dass der Prüfer weiß: Hier hat kein Mensch etwas entschieden.

Mechanik und Meinung

Die Regelsets teilen sich in zwei Gruppen, und sie werden sehr unterschiedlich behandelt.

Die Sprachsets bilden nach, was eine PHP-Fassung verlangt oder erlaubt. Was sie tun, ist im Handbuch der Sprache beschrieben, und das Ergebnis ist weitgehend eindeutig. Diese Sets sind der Grund, warum ein Sprung über zwei Hauptfassungen nicht mehr Wochen dauert.

Die Qualitäts- und Aufräumsets sind etwas anderes. Sie sind Meinungen über guten Code, und einige davon ändern Verhalten in Randfällen. Eine lockere Prüfung auf Gleichheit wird zu einer strengen, und der eine Aufruf im Projekt, der sich auf die Umwandlung verließ, verhält sich ab jetzt anders. Das Entfernen einer Zuweisung, deren Wert niemand liest, ist richtig, außer die Zuweisung rief eine Methode mit einer Nebenwirkung auf.

Die Regel dafür ist einfach: Sprachsets im Block, Qualitätssets einzeln und nur dort, wo Tests das Verhalten festhalten. Wer beides in einem Lauf hat, kann bei einem Fehler nicht sagen, ob der Sprung oder eine Meinung ihn verursacht hat.

Ohne Netz nur Mechanik

Damit ist die Voraussetzung schon gesagt und sie ist dieselbe wie bei jeder automatischen Änderung an fremdem Code: Es braucht eine Möglichkeit festzustellen, ob sich Verhalten geändert hat.

Fehlt sie, heißt das nicht, dass Rector ausscheidet. Es heißt, dass nur die Sets erlaubt sind, deren Änderungen die Sprache selbst erzwingt, und dass alles, was eine Entscheidung trifft, warten muss. Für den umgekehrten Weg gibt es einen angenehmen Nebeneffekt: Charakterisierungstests für den Bereich, der als Nächstes dran ist, sind auch dann die richtige erste Arbeit, wenn man Rector gar nicht einsetzt.

Was in jedem Fall dazugehört, ist eine statische Prüfung. Rector ändert, PHPStan sagt, was danach nicht mehr zusammenpasst, und zwar vor dem ersten Testlauf. Die beiden Werkzeuge ersetzen einander nicht, sie ergänzen sich in genau dieser Reihenfolge.

Die statische Prüfung, die nach jedem Lauf sagt, was nicht mehr zusammenpasst, steht in PHPStan Stufe für Stufe in Altcode einführen.

Wie man vierhundert Dateien prüft

Auch mit engem Zuschnitt entsteht ein Antrag mit einigen hundert geänderten Dateien, und die übliche Durchsicht ist dafür das falsche Werkzeug.

Die Umstellung, die das löst, ist klein: Geprüft wird nicht je Datei, sondern je Regel. Für jede Regel, die im Lauf aktiv war, sucht man sich drei Stellen heraus, davon eine ungewöhnliche, und liest sie genau. Stimmt die Regel an drei Stellen, stimmt sie an dreihundert, denn sie ist deterministisch.

Praktisch heißt das: --dry-run mit einer Regel nach der anderen statt aller auf einmal, und die Liste der Regeln in die Beschreibung des Antrags. Der Prüfer liest dann eine Liste von zwölf Regeln und drei Beispielen je Regel statt vierhundert Dateien, und beides zusammen dauert eine halbe Stunde.

Die Regel, die es noch nicht gibt

Der Punkt, an dem Rector von einem Upgrade-Werkzeug zu etwas anderem wird, kommt meist nach dem zweiten oder dritten Lauf, und er wird oft übersehen: Die interessanten Muster in einem gewachsenen Projekt sind projekteigen, und für die schreibt man die Regel selbst.

Der Anlass ist immer dieselbe Art von Aufgabe. Eine eigene Hilfsklasse soll durch die Sprachfunktion ersetzt werden, die es inzwischen gibt. Ein Aufruf soll überall ein zusätzliches Argument bekommen. Eine Schreibweise, auf die sich das Team geeinigt hat, soll an achthundert Stellen durchgesetzt werden. Alles davon ist per Hand ein Wochenprojekt und als Regel ein Nachmittag.

final class EigenesTrimErsetzen extends AbstractRector
{
    public function getNodeTypes(): array
    {
        return [StaticCall::class];
    }

    public function refactor(Node $knoten): ?Node
    {
        // Nur unser eigener Helfer, nichts sonst. Eine Regel,
        // die zu breit greift, ist schlimmer als keine.
        if (!$this->isName($knoten->class, 'App\Util\Text')) {
            return null;
        }
        if (!$this->isName($knoten->name, 'trimmen')) {
            return null;
        }

        // null heisst: nichts geaendert. Das ist der Normalfall
        // und muss der billigste Pfad sein.
        return new FuncCall(new Name('trim'), $knoten->args);
    }
}

Zwei Dinge daran sind die eigentliche Arbeit, nicht die zehn Zeilen. Die Regel muss eng greifen: Ein Treffer auf einen Namen ohne Prüfung der Klasse erwischt fremde Aufrufe, die zufällig gleich heißen. Und sie braucht einen Test mit einem Vorher und einem Nachher, den das Werkzeug selbst ausführt; ohne ihn ist die Regel eine Vermutung, die man auf achthundert Stellen anwendet.

Wo bei einem Framework-Sprung der Rest von Hand anfängt, steht am Beispiel Laravel in Laravel-Upgrades.

Was Rector nicht anfasst

Die Grenze zu kennen, spart die Enttäuschung nach der ersten guten Erfahrung. Rector sieht PHP, und ein gewachsenes Projekt besteht zu einem erheblichen Teil nicht aus PHP.

Nicht angefasst werden Konfigurationsdateien, Vorlagen mit eigener Syntax, SQL in Zeichenketten, JavaScript, Abhängigkeiten in der Paketdatei und alles, was über dynamische Aufrufe entsteht. Ein Aufruf, der aus einer Zeichenkette zusammengesetzt wird, ist für ein Werkzeug, das den Baum liest, unsichtbar.

Und eine Ebene darüber: Ein Framework-Wechsel, bei dem sich die Bedeutung eines Aufrufs ändert und nicht nur sein Name, ist keine Regelsache. Dafür gibt es Migrationspakete, die einen Teil abnehmen, und danach bleibt Handarbeit. Wer das vorher weiß, plant den Sprung richtig; wer es hinterher merkt, hält das Werkzeug für unzuverlässig.

Am Beispiel eines Frameworks steht dieselbe Arbeit in Symfony-Deprecations abarbeiten, ohne im Log zu ertrinken.

Vom Einmallauf zur Prüfung

Der Schritt, der aus einer Aufräumaktion einen Zustand macht, kostet zehn Minuten und wird selten gegangen: Rector bleibt im Projekt und läuft in der Bauprüfung mit --dry-run. Schlägt er an, ist der Antrag rot.

Damit hört das Zurückfallen auf. Neuer Code entsteht ab sofort in der Form, auf die das Projekt gerade gebracht wurde, und der nächste Sprung auf eine neue Sprachfassung beginnt nicht wieder bei viertausend Dateien, sondern bei den paar hundert, die die neue Fassung betrifft.

Zusammen ist das die ganze Antwort auf die Frage, warum es beim ersten Versuch nicht klappte: nicht das Werkzeug, sondern die Menge pro Schritt. Wie ein PHP-Upgrade insgesamt abläuft, steht auf einer eigenen Seite.

Dieser Artikel gehört zu einer Reihe über Systeme, die es schon gibt. Der Rückblick ordnet alle Artikel der Reihe nach Anlass.