Das Schlüsselbrett einer Hotelrezeption aus dunklem Holz, in zwei Reihen schmaler Fächer unterteilt. Über jedem Fach sitzt ein ovales Messingschild mit einer Zimmernummer, darunter ein Haken mit einem schweren Messinganhänger und dem zugehörigen Schlüssel. Einige Haken sind leer, weil ihre Schlüssel gerade unterwegs sind. Ein Sinnbild dafür, dass jede Nummer genau einen Platz hat und ein zweiter Schlüssel unter derselben Nummer nirgends hängen könnte.
Jede Nummer hat genau einen Haken. Wer einen zweiten Schlüssel aufhängen will, braucht eine neue Nummer.

PHP-Projekte haben vor allem deshalb kleine Abhängigkeitsbäume, weil die PHP-Engine pro Prozess genau eine Definition für jeden Klassennamen zulässt. Diese Regel macht jedes Versions-Constraint zu einer Entscheidung, die sich alle Pakete eines Projekts teilen, und sie zwingt das PHAR von PHPUnit dazu, seine Abhängigkeiten umzubenennen. Andrew Nesbitt hat den umgekehrten Fall aufgeschrieben: warum npm-Abhängigkeitsbäume so groß sind. Die üblichen Erklärungen dafür sind die fehlende Standardbibliothek, die Kultur und die Faulheit der Paketautoren. Seine Antwort ist der Resolver. PHP kommt in seinem Text nicht vor, sein Argument lässt sich aber Schritt für Schritt auf PHP übertragen.

Der Konfliktfehler schickt die Rechnung an die richtige Person

Nesbitt beginnt bei Ruby. Bundler wählt für die gesamte Anwendung genau eine Version jedes Gems. Jedes Versions-Constraint in jedem Gem ist ein Anspruch auf diese gemeinsame Entscheidung. Wenn sich die Ansprüche nicht vereinbaren lassen, verweigert Bundler die Installation und nennt die Gems, deren Constraints kollidiert sind. Aus dieser Verweigerung wird ein Issue im richtigen Tracker, ein Maintainer erweitert einen Versionsbereich und veröffentlicht eine neue Version, und nach jedem Major Release von Rails rollt eine Welle solcher kleinen Releases durch das Ökosystem. Ein Constraint verursacht Kosten, die andere tragen, und die Fehlermeldung schickt die Rechnung an die eine Person, die sie senken kann. Gem-Autoren reagieren entsprechend: kurze Abhängigkeitslisten, weite Versionsbereiche.

npm kennt einen solchen Fehler nicht, und der Grund liegt in der Laufzeitumgebung. Node löst Module über Dateipfade auf. Zwei Kopien desselben Pakets in verschiedenen node_modules-Verzeichnissen sind zwei verschiedene Dateien, und die Laufzeitumgebung hat nichts dagegen einzuwenden. Wenn zwei Pakete unvereinbare Versionen einer gemeinsamen Abhängigkeit verlangen, gibt npm jedem seine eigene Kopie, und die Installation gelingt. Ein Constraint kostet seinen Autor damit nichts. Kein Konflikt zwingt zwei Maintainer dazu, miteinander zu reden, und eine Abhängigkeit auf ein Paket mit zehn Zeilen Code ist so billig wie jede andere. Das ist die Voraussetzung, die die Gewohnheit der Micro-Packages gebraucht hat. Die Kosten sind trotzdem da, sie werden nur anderswo bezahlt: Jede duplizierte Kopie wird installiert, gebündelt und vergrößert die Fläche, die jemand auditieren muss. Nesbitt bringt es, aus dem Englischen übersetzt, auf einen Satz: „Abhängigkeitsbäume wachsen auf die Größe, die ihr Resolver zulässt.“

Composer hatte keine Wahl

PHP steht auf Bundlers Seite dieser Linie, und zwar entschiedener als Ruby. Composer wählt pro Projekt genau eine Version jedes Pakets. Sein Solver findet entweder eine Menge von Versionen, die alle Constraints erfüllt, oder er verweigert die Auflösung und gibt den Satz aus, den jede PHP-Entwicklerin und jeder PHP-Entwickler schon gesehen hat: „Your requirements could not be resolved to an installable set of packages“, gefolgt von den Namen der Pakete, die sich widersprechen.

Composers Autoren hatten nie die Freiheit, das anders zu entscheiden. Die PHP-Engine schlüsselt geladenen Code nach vollqualifiziertem Klassennamen. Eine Definition pro Name pro Prozess; denselben Namen ein zweites Mal zu deklarieren ist ein Fatal Error. PSR-4-Autoloading bildet jeden Klassennamen auf genau eine Datei ab. Verschachtelte node_modules-Verzeichnisse haben in PHP keine Entsprechung, weil die Verschachtelung nichts erreichen könnte. Wie viele Kopien einer Bibliothek auch auf der Festplatte liegen, nur eine von ihnen kann jemals unter ihrem Namen geladen werden. Composer löst auf genau eine Version pro Paket auf, weil die Laufzeitumgebung ihm keine Alternative lässt.

Der soziale Mechanismus folgt so, wie Nesbitt ihn für Ruby beschreibt. Eine neue Major-Version von Symfony löst auf Packagist eine Welle von Releases aus, die kaum mehr tun, als ein Constraint zu erweitern. Eine Bibliothek, die eng pinnt, sammelt Issues ein, weil ihr Pin die Upgrades anderer blockiert. PHP-Maintainer halten Abhängigkeitslisten kurz und Versionsbereiche weit, aus demselben Grund wie Gem-Autoren: Der Konfliktfehler sorgt dafür, dass sie davon erfahren, wenn sie es nicht tun.

Die Standardbibliothek erklärt nur die Nachfrage

Du kannst einwenden, dass ich die naheliegende Erklärung übersprungen habe. PHP bringt eine umfassende Standardbibliothek mit, in C implementiert, ein fester Bestandteil der Laufzeitumgebung. Stringverarbeitung, Array-Operationen, Datumsarithmetik, JSON, Hashing, Datenbankzugriff: alles da, bevor das erste composer require läuft. Ein Paket wie is-odd hat in PHP keinen Markt, weil die Sprache die Frage bereits beantwortet. JavaScript kam jahrelang mit fast nichts, und tausende npm-Pakete mit zehn Zeilen Code haben dieses Vakuum gefüllt.

Den Einwand lasse ich gelten, er trifft aber nur die Hälfte des Problems. Die Standardbibliothek erklärt die Nachfrage nach Micro-Packages; der Resolver erklärt, warum es nichts kostet, von ihnen abzuhängen. Rust trennt die beiden Faktoren. Seine Standardbibliothek ist bewusst minimal, sein Resolver ist innerhalb eines semver-kompatiblen Bereichs strikt, und das Ergebnis liegt in der Mitte: Nesbitt zitiert Armin Ronacher, der ein einfaches Rocket-Webprojekt bei 172 Crates zählt, während grundlegende Crates wie serde seit 2017 auf derselben Major-Version stehen, weil ein Breaking Release die eine Version spalten würde, die alle Abhängigen teilen müssen. Eine kleine Standardbibliothek mit striktem Resolver ergibt mittelgroße Bäume; mit einem duplizierenden Resolver ergibt sie npm.

Wie die beiden Faktoren auseinanderfallen, lässt sich in JavaScript selbst beobachten. Die Sprache bekam 2017 String.prototype.padStart, ein Jahr nach dem left-pad-Vorfall, und die Micro-Packages, die dadurch überflüssig wurden, werden immer noch millionenfach pro Woche heruntergeladen. Die Nachfrage hat diese Pakete geschaffen, und der Resolver hält sie am Leben.

PHP hat beide Faktoren auf der restriktiven Seite, eine Ecke, die es sich mit Python teilt: umfassende Standardbibliothek, eine Version pro Prozess. Deshalb löst eine typische PHP-Anwendung Dutzende Pakete auf, wo eine vergleichbare JavaScript-Anwendung Hunderte auflöst. Es liegt nicht daran, dass wir disziplinierter wären.

PHPUnit lebt in deinem Prozess

Ein Test Framework läuft im selben Prozess wie der Code, den es testet. Dein zu testender Code und der Code des Frameworks werden in eine Engine geladen, in eine Klassentabelle, unter eine Definition pro Name.

Wenn du PHPUnit mit Composer installierst, funktioniert das strikte Modell wie vorgesehen. Es gibt einen Abhängigkeitsbaum, und PHPUnits Abhängigkeiten werden zusammen mit deinen aufgelöst: eine Version von sebastian/diff, geteilt vom Framework und von allem anderen im Projekt, das sie zufällig auch benutzt. Damit kommen auch die Kosten, die Nesbitt beschreibt, und die Rechnung erreicht nicht immer auf Anhieb die richtige Person. Am Tag, an dem Symfony 3.1 erschien, bat mich jemand in #2180, PHPUnits Anforderung an symfony/yaml anzuheben. Die erlaubte Symfony 3.1 aber längst. Der Konflikt lag zwei Ebenen tiefer: PHPUnit verlangte phpspec/prophecy, Prophecy verlangte phpdocumentor/reflection-docblock in Version 2, und diese Version vertrug sich nicht mit Symfony 3.1. Composer hat die ganze Kette ausgegeben, und die Lösung musste von den Maintainern von Prophecy kommen. Manchmal stelle ich die Rechnung auch mir selbst aus. PHPUnit 7.0 verlangte sebastian/diff in Version 3, phpcov 4.0.5 verlangte Version 1 oder 2, und wer beide Werkzeuge im selben Projekt benutzte, konnte nicht aktualisieren (#2990). Beide Constraints stammten von mir.

Die PHAR-Distribution von PHPUnit kann an dieser Vereinbarung nicht teilnehmen. Sie bringt eine feste Menge von Abhängigkeiten in einen Prozess, dessen Abhängigkeitsbaum sie nie gesehen hat. Dort gilt die Regel der Engine: Welche Kopie zuerst geladen wird, besitzt den Namen. Die zweite stirbt entweder an einem Fatal Error oder wird gar nicht erst geladen, und Code läuft gegen Klassen der falschen Version. Im Dezember 2015 beschrieb #2014, wie ein global installiertes PHPUnit Klassen aus seiner eigenen Umgebung vor denen des Projekts lud und die Testsuite mit einem Fatal Error abbrach, weil GuzzleHttp\Client nicht zu dem bereits geladenen GuzzleHttp\ClientInterface passte. Jordi Boggiano verwies schon damals auf php-scoper, und ich legte am nächsten Tag #2015 an: alle gebündelten Abhängigkeiten in einen eigenen Namensraum verschieben. Bis das umgesetzt war, hatte ich nur einen Rat. Als das PHAR im September 2016 in einem Symfony-2-Projekt an einer doppelten Deklaration der Klassen aus symfony/yaml scheiterte (#2285), lautete meine Antwort: das PHAR in einer solchen Situation nicht benutzen.

Also tut das PHAR, was npm tut, von Hand nachgebaut: Es benennt um. Beim Build versieht php-scoper die Namensräume aller gebündelten Abhängigkeiten mit einem Präfix, und aus SebastianBergmann\Exporter wird PHPUnitPHAR\SebastianBergmann\Exporter. Zwei Versionen derselben Bibliothek können dann in einem Prozess koexistieren, weil sie für die Engine unterschiedlicher Code sind. Bis dahin vergingen mehr als drei Jahre. Sebastian Feldmann hat das Präfix in #3575 auf der Grundlage früherer Arbeit von Théo Fidry und anderen in den Build eingebaut, im März 2019 habe ich es übernommen, und seit PHPUnit 8.3 im August 2019 wird jedes PHAR so gebaut. Wie ernst die Engine die Umbenennung nimmt, zeigte sich schon beim ersten Test. Als Sebastian Feldmann PHPUnits eigene Testsuite mit dem neuen PHAR ausführte, scheiterte ein Test an einem TypeError: Eine Methode erwartete ein PHPUnit\SebastianBergmann\Comparator\ComparisonFailure und bekam ein SebastianBergmann\Comparator\ComparisonFailure. Derselbe Code unter zwei Namen ist für die Engine zwei verschiedene Klassen.

Dieselbe Asymmetrie zeigt sich in npms Notausgang. npm dupliziert standardmäßig und brauchte deshalb peerDependencies für die Pakete, die kaputtgehen, sobald es sie doppelt gibt. Der bekannte Fall ist React, wo zwei Kopien in einer Anwendung die Hooks zerstören, weil der Zustand, auf den sie sich stützen, in einem Modul liegt. PHP teilt standardmäßig und braucht deshalb überall dort eine eigene Build-Maschinerie, wo Duplikation erwünscht ist. Jedes Ökosystem baut von Hand nach, was das andere geschenkt bekommt.

Das PHAR legt offen, was es dupliziert

Duplikation hat ihren Preis, und das PHAR zahlt ihn wissentlich. Jede gebündelte Kopie ist eine Komponente mehr, die ausgeliefert wird, und ein Eintrag mehr auf der Fläche, die jemand auditieren muss. Deshalb führt es Buch über seine Kopien. --manifest listet jedes gebündelte Paket mit seiner Version, --sbom gibt ein CycloneDX-Dokument aus, und --composer-lock gibt die Lock-Datei aus, aus der das PHAR gebaut wurde. Ich habe das in meinem Artikel über den MG4-Fall beschrieben. Das Präfix benennt Namensräume um, die Pakete und ihre Versionen in der Stückliste bleiben dieselben. npms duplizierte Kopien sammeln sich still in den Tiefen von node_modules. Die des PHARs sind umbenannt, aufgelistet und signiert.

Dieselbe Überlegung gilt in die andere Richtung, für die Abhängigkeiten, die PHPUnit eingeht. Jedes Paket, das ich zu PHPUnits composer.json hinzufüge, wird zu einem Constraint, mit dem jedes Projekt leben muss, das PHPUnit benutzt. symfony/yaml war ein solches Paket. PHPUnit brauchte es für eine einzige Klasse, den TAP-Logger, der seine Diagnosen als YAML ausgab, und trotzdem musste sich jedes Symfony-Projekt mit PHPUnit auf eine Version dieser Komponente einigen. Mit dem TAP-Logger ist in PHPUnit 6.0 im Februar 2017 auch diese Abhängigkeit verschwunden.

Bei symfony/process habe ich mich gegen die Abhängigkeit entschieden, obwohl die Prozessisolation von PHPUnit davon profitiert hätte. 2014 hatte ich dem Vorschlag in #1342 noch zugestimmt, PHPUnits eigenen Code rund um proc_open() durch die Symfony-Komponente zu ersetzen. 2020 habe ich das Ticket geschlossen, ohne dass es umgesetzt war. Eine Komponente, die in fast jedem Symfony-Projekt ohnehin installiert ist, hätte PHPUnit an die Versionen gebunden, die diese Projekte verwenden, und diese Projekte an die Versionen, die PHPUnit verträgt. Den Code für die Prozessisolation pflege ich deshalb weiterhin selbst. Aus demselben Grund ist PHPUnits Baum flach, und die meisten sebastian/*-Pakete hängen von nichts ab außer von PHP selbst.