Von Funktionssammlungen zu wartbaren Klassen
Viele gewachsene PHP-Projekte bestehen aus hunderten freien Funktionen, die über include-Dateien lose zusammengehalten werden. Dieser Artikel zeigt, wie man solche prozeduralen Codebasen schrittweise in objektorientiertes PHP überführt, mit erkennbaren Extraktionsmustern statt eines riskanten kompletten Neuschreibens.
Inhaltsverzeichnis
- 1. Warum prozedurales PHP an Grenzen stößt
- 2. Funktionsgruppen als versteckte Klassen erkennen
- 3. Die erste Klasse extrahieren: ein Praxisbeispiel
- 4. Impliziten Zustand in Eigenschaften kapseln
- 5. Assoziative Arrays durch Objekte ersetzen
- 6. Abwärtskompatible Fassaden für alte Aufrufer
- 7. Die richtige Reihenfolge: Bottom-up statt Top-down
- 8. Characterization Tests vor der Migration schreiben
- 9. Prozedural vs. objektorientiert im direkten Vergleich
- 10. Zusammenfassung
- 11. FAQ
1. Warum prozedurales PHP an Grenzen stößt
Prozedurales PHP ist nicht per se schlecht, für kleine Skripte mit klarem Ablauf ist es oft die schnellste Lösung. Das Problem entsteht, wenn ein solches Skript über Jahre wächst und Funktionen anfangen, sich gegenseitig aufzurufen, gemeinsamen Zustand über globale Variablen zu teilen und in include-Dateien verstreut zu liegen, ohne dass eine klare Struktur erkennbar bleibt. Genau an diesem Punkt beginnt die Migration zu objektorientiertem PHP sich auszuzahlen, weil Klassen Struktur, Kapselung und Namensräume mitbringen, die reine Funktionen nicht bieten.
Der typische Schmerzpunkt zeigt sich beim Onboarding neuer Entwickler: In einer prozeduralen Codebasis mit hunderten Funktionen in einer flachen Namensraumhierarchie ist unklar, welche Funktion zu welchem fachlichen Bereich gehört und welche Daten sie tatsächlich benötigt. Eine Funktion wie berechne_versandkosten($bestellung) greift in der Praxis oft still auf ein Dutzend globaler Variablen zu, die nirgends in der Signatur auftauchen. Diese Unsichtbarkeit von Abhängigkeiten ist der eigentliche Grund, warum prozedurales PHP in großen Projekten unwartbar wird, nicht die Syntax selbst.
Die Migration zu objektorientiertem Code löst dieses Problem nicht magisch, sondern macht es sichtbar und bearbeitbar: Eine Klasse mit typisierten Konstruktor-Parametern zwingt dazu, jede Abhängigkeit explizit zu benennen. Der Rest dieses Artikels zeigt, wie man von einer Sammlung freier Funktionen zu einer Menge kohärenter Klassen kommt, ohne die Anwendung während der Migration lahmzulegen.
2. Funktionsgruppen als versteckte Klassen erkennen
Der erste Schritt jeder Migration ist keine Codeänderung, sondern eine Analyse: Welche Funktionen in der bestehenden Codebasis operieren wiederholt auf denselben Daten? Eine Gruppe von Funktionen, die alle ein $bestellung-Array als ersten Parameter entgegennehmen und verändern, ist in Wahrheit eine Klasse, die noch nicht als solche erkannt wurde. Diese Beobachtung, oft als "Datenklumpen" (Data Clumps) bezeichnet, ist eines der zuverlässigsten Signale für eine sinnvolle Klassenextraktion.
Praktisch findet man solche Gruppen, indem man Funktionsnamen nach gemeinsamen Präfixen durchsucht, etwa bestellung_berechnen, bestellung_validieren, bestellung_stornieren. Dieses Namensmuster ist meist kein Zufall, sondern eine informelle, in der Sprache selbst nicht abgebildete Klasse. Ein gutes Werkzeug für diese Analyse ist ein einfaches Skript, das per Regex alle Funktionsdefinitionen einer Datei extrahiert und nach gemeinsamen Präfixen gruppiert, um einen ersten Überblick über mögliche Klassen zu bekommen, bevor überhaupt eine Zeile Code geändert wird.
<?php
// Legacy procedural functions — all operate on the same $order array
// This is a hidden class that has not been recognized as such yet
function bestellung_gesamtsumme(array $bestellung): float
{
$summe = 0.0;
foreach ($bestellung['positionen'] as $position) {
$summe += $position['preis'] * $position['menge'];
}
return $summe;
}
function bestellung_ist_versandfaehig(array $bestellung): bool
{
return $bestellung['status'] === 'bezahlt'
&& !empty($bestellung['lieferadresse']);
}
function bestellung_versandkosten(array $bestellung): float
{
$gewicht = array_sum(array_column($bestellung['positionen'], 'gewicht'));
return $gewicht > 5.0 ? 9.90 : 4.90;
}
// Somewhere else in the codebase, the same array is passed around
// with no guarantee about its shape or invariants
$bestellung = ['positionen' => [], 'status' => 'neu', 'lieferadresse' => null];
$summe = bestellung_gesamtsumme($bestellung);
Alle drei Funktionen operieren auf demselben Array, ohne dass eine gemeinsame Struktur oder Validierung existiert. Jede Funktion muss selbst prüfen, ob die erwarteten Schlüssel überhaupt vorhanden sind, was zu wiederholtem, defensivem Code führt. Genau diese Wiederholung ist das Signal, dass $bestellung eine echte Klasse werden sollte.
3. Die erste Klasse extrahieren: ein Praxisbeispiel
Die Extraktion beginnt mit der Definition einer Klasse, die die bisherigen Array-Schlüssel als typisierte Eigenschaften abbildet, und Methoden, die den Inhalt der vorherigen Funktionen übernehmen. Wichtig ist, die Methode zunächst so nah wie möglich am ursprünglichen Verhalten zu halten, statt gleichzeitig Fachlogik zu verbessern. Verhalten und Struktur getrennt zu ändern ist der Kern von sicherem Refactoring, jede Vermischung von beidem erhöht das Risiko, unbemerkt einen Bug einzuführen.
Im folgenden Beispiel wird aus den drei freien Funktionen eine Klasse Order, deren Konstruktor die vorher lose zusammengehaltenen Daten zusammenfasst und deren Methoden die identische Fachlogik wie zuvor ausführen, nur jetzt gekapselt und mit garantierter Datenstruktur.
<?php
declare(strict_types=1);
final class OrderLine
{
public function __construct(
public readonly float $price,
public readonly int $quantity,
public readonly float $weight,
) {
}
}
// Extracted class replaces the loosely structured $bestellung array
final class Order
{
/** @param OrderLine[] $lines */
public function __construct(
private readonly array $lines,
private readonly string $status,
private readonly ?string $shippingAddress,
) {
}
public function total(): float
{
$sum = 0.0;
foreach ($this->lines as $line) {
$sum += $line->price * $line->quantity;
}
return $sum;
}
public function isReadyToShip(): bool
{
return $this->status === 'paid' && $this->shippingAddress !== null;
}
public function shippingCost(): float
{
$weight = array_sum(array_map(
static fn (OrderLine $line): float => $line->weight,
$this->lines
));
return $weight > 5.0 ? 9.90 : 4.90;
}
}
$order = new Order(
lines: [new OrderLine(price: 19.99, quantity: 2, weight: 0.5)],
status: 'paid',
shippingAddress: 'Musterstraße 1, 12345 Berlin',
);
echo $order->total();
Der entscheidende Unterschied zur prozeduralen Variante liegt nicht in der Funktionalität, sondern in der Garantie: Ein Order-Objekt kann nicht in einem inkonsistenten Zwischenzustand existieren, weil der Konstruktor alle notwendigen Daten verlangt. Bei der Array-Variante konnte jeder Aufrufer versehentlich einen Schlüssel vergessen, ohne dass PHP dies zur Entwicklungszeit bemerkt hätte.
4. Impliziten Zustand in Eigenschaften kapseln
Neben Funktionsgruppen, die auf Arrays operieren, findet man in prozeduralem Code häufig impliziten Zustand über statische Funktionsvariablen oder Dateien, die als primitive Datenbank dienen. Dieser Zustand ist besonders tückisch, weil er zwischen Aufrufen bestehen bleibt, ohne dass das im Funktionsnamen sichtbar wird. Eine Funktion naechste_bestellnummer(), die intern eine statische Variable hochzählt, verhält sich bei jedem Aufruf anders, obwohl die Signatur keine Parameter zeigt.
Die objektorientierte Entsprechung ist eine Klasse mit einer privaten Eigenschaft, die den Zustand explizit hält, und deren Instanz gezielt dort erzeugt wird, wo der Zustand benötigt wird. Dadurch wird aus einem unsichtbaren, global geteilten Zustand ein lokal kontrollierter, dessen Lebensdauer über die Objektinstanz sichtbar ist. Genau dieser Schritt, impliziten in expliziten Zustand zu überführen, ist oft wertvoller als die reine Umbenennung von Funktionen in Methoden, weil er die eigentliche Fehlerquelle beseitigt.
<?php
// Legacy: implicit state hidden inside a static function variable
function naechste_bestellnummer(): int
{
static $counter = 1000;
return $counter++;
}
declare(strict_types=1);
// Object-oriented equivalent: state is explicit, lifetime is visible
final class OrderNumberGenerator
{
public function __construct(
private int $counter = 1000,
) {
}
public function next(): int
{
return $this->counter++;
}
}
// Two independent generators, no shared hidden state between them
$generatorA = new OrderNumberGenerator();
$generatorB = new OrderNumberGenerator(counter: 5000);
echo $generatorA->next(); // 1000
echo $generatorB->next(); // 5000
5. Assoziative Arrays durch Objekte ersetzen
Assoziative Arrays sind in prozeduralem PHP das universelle Werkzeug für strukturierte Daten, bringen aber keinerlei Garantien mit: Ein Tippfehler im Schlüssel wird erst zur Laufzeit sichtbar, oft als stille null-Rückgabe statt eines Fehlers. Die Migration zu Objekten löst dieses Problem strukturell, weil PHPStan oder Psalm bereits zur Entwicklungszeit erkennen, wenn auf eine nicht existierende Eigenschaft zugegriffen wird, während ein Tippfehler in einem Array-Schlüssel unbemerkt bleibt.
Ein bewährter Zwischenschritt bei größeren Migrationen ist, zunächst nur die am häufigsten verwendeten Arrays in Value Objects umzuwandeln, während seltener genutzte Strukturen vorerst Arrays bleiben. Diese Priorisierung nach Nutzungshäufigkeit sorgt dafür, dass der größte Teil des Typsicherheitsgewinns mit dem geringsten Migrationsaufwand erreicht wird, statt alle Arrays gleichzeitig und mit gleicher Priorität umzubauen.
6. Abwärtskompatible Fassaden für alte Aufrufer
Eine Migration, die alle Aufrufer einer Funktion gleichzeitig auf die neue Klasse umstellen muss, ist in großen Projekten selten praktikabel. Der bewährte Ansatz ist, die ursprüngliche Funktion als dünne Fassade bestehen zu lassen, die intern die neue Klasse instanziiert und deren Methode aufruft. Dadurch funktioniert alter Code unverändert weiter, während neuer Code bereits direkt mit der Klasse arbeitet.
<?php
declare(strict_types=1);
// Backward-compatible facade: old callers keep working unchanged
function bestellung_gesamtsumme(array $bestellung): float
{
trigger_error(
'bestellung_gesamtsumme() is deprecated, use Order::total() instead',
E_USER_DEPRECATED
);
$lines = array_map(
static fn (array $item): OrderLine => new OrderLine(
price: (float) $item['preis'],
quantity: (int) $item['menge'],
weight: (float) ($item['gewicht'] ?? 0.0),
),
$bestellung['positionen'] ?? []
);
$order = new Order(
lines: $lines,
status: $bestellung['status'] ?? 'neu',
shippingAddress: $bestellung['lieferadresse'] ?? null,
);
return $order->total();
}
Der E_USER_DEPRECATED-Hinweis macht sichtbar, wie oft die alte Funktion noch aufgerufen wird, ohne den Betrieb zu unterbrechen. In Kombination mit einem Log-Handler, der solche Meldungen zentral sammelt, entsteht eine ehrliche, messbare Kennzahl für den Migrationsfortschritt, ganz ohne aufwendige manuelle Codesuche.
7. Die richtige Reihenfolge: Bottom-up statt Top-down
Ein häufiger Fehler bei der Migration ist, mit der größten, zentralsten Funktion zu beginnen, weil sie den größten wahrgenommenen Nutzen verspricht. In der Praxis ist genau das Gegenteil sinnvoller: Man beginnt mit kleinen, blattartigen Funktionen ohne Abhängigkeiten zu anderen Funktionsgruppen, extrahiert diese zuerst in Klassen, und arbeitet sich dann schrittweise zu den zentraleren, stärker vernetzten Funktionen vor.
Dieser Bottom-up-Ansatz hat einen praktischen Grund: Kleine, isolierte Funktionen lassen sich leicht mit Characterization Tests absichern, bevor sie umgebaut werden, während zentrale Funktionen mit vielen Abhängigkeiten ein deutlich höheres Risiko für unbeabsichtigte Verhaltensänderungen tragen. Wer zuerst die einfachen Fälle migriert, sammelt zudem Erfahrung mit dem eigenen Extraktionsprozess, bevor die komplexeren, risikoreicheren Teile der Codebasis an der Reihe sind.
8. Characterization Tests vor der Migration schreiben
Bevor eine Funktion in eine Klasse überführt wird, sollte ein Test existieren, der das aktuelle, tatsächliche Verhalten der Funktion dokumentiert, unabhängig davon, ob dieses Verhalten fachlich korrekt ist. Solche Characterization Tests sind kein Ersatz für eine vollständige Testabdeckung, sie erfüllen einen anderen Zweck: Sie garantieren, dass die Migration das Verhalten nicht unbeabsichtigt verändert, selbst wenn das ursprüngliche Verhalten Kanten oder Sonderfälle enthält, die niemand mehr bewusst kennt.
In der Praxis reicht dafür oft ein einfacher Test, der die alte Funktion mit repräsentativen Eingaben aufruft und die Ausgabe protokolliert, ohne eine explizite Erwartung zu formulieren, sondern das aktuelle Ergebnis als Golden Master festzuhalten. Nach der Extraktion in die Klasse läuft derselbe Test gegen die neue Methode, und jede Abweichung vom Golden Master zeigt sofort eine unbeabsichtigte Verhaltensänderung, bevor sie in Produktion sichtbar wird.
<?php
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
// Characterization test: documents current behavior, not required
// correctness — captures the golden master before extraction
final class OrderTotalCharacterizationTest extends TestCase
{
public function testGoldenMasterBeforeExtraction(): void
{
$order = ['lines' => [
['preis' => 19.99, 'menge' => 2, 'gewicht' => 0.5],
], 'status' => 'neu', 'lieferadresse' => null];
// Golden master value observed from the legacy function,
// not derived from a spec — just what it currently returns
self::assertSame(39.98, bestellung_gesamtsumme($order));
}
public function testGoldenMasterAfterExtraction(): void
{
// Same input, same golden master, now against the new class
$lines = [new OrderLine(price: 19.99, quantity: 2, weight: 0.5)];
$order = new Order(lines: $lines, status: 'neu', shippingAddress: null);
self::assertSame(39.98, $order->total());
}
}
9. Prozedural vs. objektorientiert im direkten Vergleich
Die folgende Tabelle stellt die wichtigsten strukturellen Unterschiede zwischen prozeduralem und objektorientiertem PHP anhand konkreter Kriterien gegenüber, die bei der Migrationsentscheidung eine Rolle spielen.
| Kriterium | Prozedural | Objektorientiert |
|---|---|---|
| Datenkonsistenz | Array-Schlüssel können fehlen, keine Garantie | Konstruktor erzwingt vollständige Daten |
| Statische Analyse | Tippfehler in Array-Schlüsseln unentdeckt | PHPStan erkennt falsche Eigenschaftszugriffe |
| Namensraum | Alle Funktionen im globalen Namensraum | Klassen mit PSR-4-Namespaces gruppiert |
| Testbarkeit | Globale Abhängigkeiten schwer austauschbar | Konstruktor-Injection ermöglicht Mocks |
| Erweiterbarkeit | Neue Fälle erfordern if/switch-Ketten | Neue Klassen über Interfaces anschließbar |
Die Tabelle zeigt keinen Freifahrtschein für pauschale Umbauten: Kleine, einmalige Skripte profitieren selten von einer vollständigen Klassenstruktur. Für gewachsene, langlebige Anwendungen mit mehreren Entwicklern überwiegen die Vorteile objektorientierter Strukturen jedoch deutlich, insbesondere bei Datenkonsistenz und Testbarkeit.
Mironsoft
PHP-Legacy-Modernisierung und Magento-Entwicklung
Prozedurales PHP endlich in wartbare Klassen überführen?
Wir identifizieren versteckte Funktionsgruppen in eurer Codebasis, extrahieren Klassen schrittweise und sichern jeden Migrationsschritt mit Characterization Tests ab.
Struktur-Analyse
Funktionsgruppen und versteckte Klassen im Bestandscode identifizieren
Schrittweise Extraktion
Klassen bottom-up extrahieren, mit abwärtskompatiblen Fassaden
Absicherung
Characterization Tests vor jeder Migrationsstufe schreiben
10. Zusammenfassung
Die Migration von prozeduralem zu objektorientiertem PHP ist keine reine Syntaxübung, sondern eine strukturelle Verbesserung: Klassen machen implizite Abhängigkeiten sichtbar, erzwingen konsistente Daten über Konstruktoren und ermöglichen statische Analyse, die bei Arrays und freien Funktionen nicht möglich ist. Der Ausgangspunkt jeder Migration ist die Analyse bestehender Funktionsgruppen, die auf denselben Daten operieren und in Wahrheit bereits eine Klasse bilden, ohne dass diese Klasse je benannt wurde.
Der sichere Weg führt über Bottom-up-Extraktion, beginnend bei kleinen, isolierten Funktionen, abgesichert durch Characterization Tests, die das bestehende Verhalten dokumentieren, bevor es umgebaut wird. Abwärtskompatible Fassaden mit E_USER_DEPRECATED-Hinweisen ermöglichen, alten und neuen Code parallel zu betreiben, bis alle Aufrufer migriert sind. Am Ende steht eine Codebasis, in der objektorientiertes PHP nicht aufgezwungen wirkt, sondern die tatsächliche fachliche Struktur der Anwendung sichtbar macht.
Prozedural zu objektorientiert migrieren — Das Wichtigste auf einen Blick
Analyse
Funktionsgruppen mit gemeinsamem Präfix oder gemeinsamen Datenparametern sind versteckte Klassen.
Extraktion
Konstruktor erzwingt vollständige Daten, Methoden übernehmen die Fachlogik unverändert.
Reihenfolge
Bottom-up: kleine, isolierte Funktionen zuerst, zentrale, vernetzte Funktionen zuletzt.
Absicherung
Characterization Tests vor jeder Extraktion, abwärtskompatible Fassaden für alte Aufrufer.