Alle Artikel
12. September 2026
8 mins

Composer als Archäologie

Von Tim Rutte, Cloud & Software ArchitectThemaLegacy & Modernisierung

Ein Fundtablett mit zwölf Fächern voller Keramikscherben; quer darüber liegt ein Pinsel mit blauem Griff.

Bei der Übernahme eines alten PHP-Projekts gibt es einen Moment, der über die nächsten zwei Wochen entscheidet. Er tritt ein, wenn jemand composer install tippt und feststellt, dass es keine composer.lock gibt. Oder dass es sie gibt, sie aber nicht zu dem passt, was in vendor/ liegt. Oder dass vendor/ im Repository eingecheckt ist und niemand sagen kann, wann das zuletzt aktualisiert wurde.

Das ist keine Randerscheinung. In Projekten, die vor 2016 begonnen haben, ist es eher der Normalfall, und es hat einen einfachen Grund: Composer und das Lockfile setzten sich erst durch, als diese Projekte schon liefen.

Dieser Artikel beschreibt, wie man den Abhängigkeitsbaum eines solchen Projekts rekonstruiert, bevor man ihn anhebt. Die Reihenfolge ist wichtig: Ein Upgrade auf einem Fundament, das niemand kennt, ist keine Migration, sondern ein Versuch.

Warum das vor dem Upgrade kommt

Die Versuchung ist groß, das Fundament zu überspringen. Man kennt ja das Ziel: aktuelle PHP-Version, aktuelle Pakete. Warum also erst archäologisch arbeiten?

Weil ohne diesen Schritt zwei Fragen unbeantwortet bleiben, die später jede Fehlersuche vergiften.

Läuft in Produktion das, was im Repository steht? Solange das nicht geklärt ist, ist jeder Test auf der Entwicklungsmaschine eine Aussage über ein anderes System. Ich habe Projekte gesehen, bei denen ein Paket in Produktion drei Nebenversionen weiter war als im Repository, weil jemand es vor Jahren direkt auf dem Server aktualisiert hatte.

Ist etwas von Hand geändert worden? Ein gepatchtes Paket im vendor/-Verzeichnis überlebt kein composer update. Wenn der Patch einen Fehler behebt, der sonst niemandem aufgefallen ist, kommt dieser Fehler beim Upgrade zurück, und niemand verbindet ihn mit dem Upgrade.

Beide Fragen sind in ein bis zwei Tagen beantwortbar. Beide zu überspringen kostet später ein Vielfaches.

Schritt 1: Was ist tatsächlich installiert?

Die verlässlichste Quelle ist nicht die composer.json, sondern das, was in Produktion liegt. Seit Composer 2 steht dort eine Datei, die den Zustand vollständig beschreibt:

# Auf dem Produktionsserver, nicht lokal
php -r 'foreach (require "vendor/composer/installed.php" as $k => $v) {
    if ($k !== "versions") continue;
    foreach ($v as $paket => $info)
        echo $paket, " ", $info["pretty_version"] ?? "?", PHP_EOL;
}' | sort

Gibt es die Datei nicht, weil es noch Composer 1 ist, liefert die ältere installed.json dasselbe:

php -r '$d = json_decode(file_get_contents("vendor/composer/installed.json"), true);
foreach ($d["packages"] ?? $d as $p) echo $p["name"], " ", $p["version"], PHP_EOL;' | sort

Diese Liste ist die Wahrheit. Alles Weitere wird gegen sie geprüft, und der erste Vergleich ist der mit dem Repository:

diff <(ssh server 'cd /var/www/app && php -r "..."') \
     <(php -r "..." )

Jede abweichende Zeile ist ein eigener Fund und gehört notiert, bevor irgendetwas geändert wird.

Schritt 2: Welche Pakete sind von Hand geändert?

Das ist der Fund, der am meisten Zeit spart, weil er sonst erst nach dem Upgrade auffällt, und zwar als unerklärlicher Fehler.

Composer legt zu jedem installierten Paket die Quelle und die Version ab. Damit lässt sich jedes Paket gegen seinen Originalzustand vergleichen. Das ist Handarbeit für einen Nachmittag und lässt sich zusammenschieben:

mkdir -p /tmp/original
while read -r paket version; do
  ziel="/tmp/original/${paket//\//_}"
  composer create-project --no-install --no-scripts --quiet \
    "$paket:$version" "$ziel" 2>/dev/null || continue
  if ! diff -rq "$ziel" "vendor/$paket" >/dev/null 2>&1; then
    echo "GEAENDERT: $paket $version"
  fi
done < installierte-pakete.txt

Praktisch funktioniert das nicht für jedes Paket, weil manche beim Veröffentlichen Dateien ausschließen. Für die Pakete, bei denen es funktioniert, findet es zuverlässig jede Änderung, und das sind meist genau die interessanten: die kleinen Bibliotheken, an denen jemand mal eben etwas anpassen musste.

Zu jedem Fund gehört danach eine Entscheidung, und es gibt nur drei mögliche.

  • Der Patch ist im aktuellen Paket enthalten. Häufiger als gedacht: Jemand hat einen Fehler behoben, der später auch beim Anbieter behoben wurde. Dann fällt der Patch beim Upgrade einfach weg.
  • Der Patch wird gebraucht und ist beim Anbieter nicht angekommen. Dann gehört er in eine Patch-Datei, die beim Installieren angewendet wird, und nicht in das Verzeichnis, das jedes Update überschreibt.
  • Niemand weiß, wofür der Patch war. Der häufigste Fall. Dann wird er entfernt, und die Stelle bekommt einen Test. Wenn der Patch nötig war, zeigt sich das jetzt, unter kontrollierten Bedingungen, und nicht in drei Monaten in Produktion.

Schritt 3: Das Lockfile herstellen

Mit der Liste aus Schritt 1 lässt sich ein Lockfile erzeugen, das genau den heutigen Stand festschreibt. Der Reflex, hier gleich aktuelle Versionen zu nehmen, ist falsch: Das wäre das Upgrade, und das Upgrade ist der nächste Schritt, nicht dieser.

# Jede Version exakt festnageln, so wie sie heute laeuft
while read -r paket version; do
  composer require --no-update "$paket:$version"
done < installierte-pakete.txt

composer update --lock   # nur das Lockfile schreiben, nichts anheben

Danach kommt die Prüfung, ohne die der ganze Schritt wertlos ist: eine frische Installation aus dem neuen Lockfile, verglichen mit dem, was in Produktion liegt.

rm -rf vendor && composer install
diff -rq vendor/ /tmp/produktions-vendor/ | grep -v "\.git"

Was hier noch abweicht, sind entweder die gepatchten Pakete aus Schritt 2 oder Pakete, die es in dieser Version nicht mehr gibt. Der zweite Fall ist der unangenehme, und er führt zum nächsten Abschnitt.

Ab diesem Punkt ist das Projekt reproduzierbar, und das ist ein Meilenstein für sich. Er gehört in einen eigenen Commit mit einer Nachricht, die sagt, dass hier nichts angehoben wurde.

Die Fälle, die sich nicht auflösen lassen

Drei Situationen tauchen regelmäßig auf, in denen ein Paket nicht mehr installierbar ist. Alle drei haben eine Antwort, und alle drei brauchen eine Entscheidung statt eines Tricks.

Die Version ist zurückgezogen. Selten, aber es kommt vor. Wenn die nächstliegende noch verfügbare Version funktioniert, wird sie genommen und der Unterschied geprüft. Wenn nicht, gilt der nächste Fall.

Das Paket gibt es nicht mehr. Der Anbieter hat es entfernt, das Repository ist verschwunden. Hier hilft nur, das zu nehmen, was in Produktion liegt, und es als eigenes Paket in ein privates Verzeichnis zu legen. Das ist keine schöne Lösung. Sie ist ehrlicher als ein Verzeichnis, das nur noch auf einem Server existiert.

Es ist ein internes Paket ohne Repository. Der Code liegt in vendor/, wurde von einem Dienstleister geliefert und hat nie in einer Versionsverwaltung gelegen. Dann wird er zu Ihrem Code: in ein eigenes Repository, mit einer Version, und mit einer Notiz, woher er kam.

In allen drei Fällen ist das Ergebnis dasselbe: Was vorher stillschweigend auf einem Server lag, liegt danach an einem benannten Ort mit einem Eigentümer. Der Rest des Upgrades wird dadurch ein normales Vorhaben.

Ein aufgegebenes Paket ist selten nur ein Wartungsproblem: Zehn Lücken, die in jedem alten PHP-System stecken.

Schritt 4: Aufgegebene Pakete, bevor sie wehtun

Jetzt, und erst jetzt, lohnt der Blick nach vorn. Mit einem funktionierenden Lockfile beantwortet Composer selbst die Frage, was beim Upgrade teuer wird:

composer audit                 # bekannte Luecken
composer outdated --direct     # Abstand zur gepflegten Version
composer why-not php 8.4       # wer blockiert die Ziel-PHP-Version?

Der dritte Befehl ist der nützlichste und der am wenigsten bekannte. Er beantwortet in einer Zeile, welche Pakete den Sprung verhindern, und macht aus einem Gefühl eine Liste.

Diese Liste sortiert sich in dieselben drei Kategorien wie bei jedem Upgrade: veraltet aber gepflegt, aufgegeben mit Nachfolger, aufgegeben ohne Nachfolger. Die dritte Kategorie bestimmt die Laufzeit des ganzen Vorhabens, und sie steht jetzt fest, bevor jemand eine Schätzung abgegeben hat.

Damit die Frage nach dem Stand der Pakete nicht alle zwei Jahre neu gestellt wird, gehört sie in einen Kalender: Ein EOL-Kalender für den ganzen Stack.

Damit es nicht wieder passiert

Zum Schluss die drei Dinge, die verhindern, dass in fünf Jahren jemand dieselbe Arbeit noch einmal macht. Alle drei sind klein, und alle drei fehlten in jedem Projekt, in dem ich diese Archäologie betreiben musste.

Das Lockfile gehört ins Repository, das vendor/-Verzeichnis nicht. Umgekehrt war einmal eine verbreitete Empfehlung, und ihre Kosten zeigen sich genau hier.

Die Installation läuft im Build, nicht auf dem Zielserver. Solange jemand auf dem Server installieren kann, wird dort irgendwann jemand installieren, und der Stand läuft wieder auseinander.

Ein Lauf prüft regelmäßig, ob Produktion und Repository übereinstimmen. Ein Vergleich der Prüfsummen, einmal die Woche, mit einer Meldung bei Abweichung. Das ist eine halbe Stunde Arbeit und es beendet eine ganze Klasse von Überraschungen.

Wie der Sprung danach abläuft, welche Brüche wirklich wehtun und welche Verhaltensänderungen still passieren, steht in einem eigenen Artikel: PHP 7.4 auf PHP 8.4. Und wie ich ein PHP-Upgrade insgesamt aufsetze, 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.