Composer richtig einsetzen: Best Practices für PHP-Projekte

Composer richtig einsetzen: Best Practices für PHP-Projekte

Die Verwaltung von Abhängigkeiten in PHP ist mit Composer deutlich einfacher geworden. Der Release von Composer 2.0 ist ein guter Anlass, ein paar Best Practices zu teilen.

Was ist Composer?

Composer ist ein Abhängigkeitsmanager für PHP, vergleichbar mit NPM oder Yarn für JavaScript und pip für Python. Er vereinfacht die Verwaltung der Pakete, von denen deine Anwendung abhängt. Als zusätzlichen „Service“ erlaubt Composer den Paketen außerdem, einen Autoloader zu registrieren. Diese Kombination macht Composer aus meiner Sicht zu einem der kritischsten Werkzeuge im PHP-Ökosystem.

Das meistgenutzte Paket-Repository von Composer ist Packagist.org. Dort werden nur Open-Source-Pakete aufgenommen. Wer den vollen Funktionsumfang von Composer braucht, kann auf das kostenpflichtige Angebot zurückgreifen. Es handelt sich um eine bezahlte Packagist-Variante der Composer-Macher, mit der sich Closed-Source-Pakete mit feingranularer Zugriffskontrolle verwenden lassen.

Wie du Composer installierst und konfigurierst, erkläre ich hier nicht. Die Maintainer beschreiben das ausführlich auf der Startseite von Composer. Einführung zu Composer auf der Projektseite. In allen Beispielen mit der Composer-CLI gehe ich davon aus, dass Composer global installiert ist und über composer aufgerufen wird.

Reguläre Abhängigkeiten von Dev-Abhängigkeiten trennen

Composer unterscheidet zwei Arten von Abhängigkeiten: reguläre Abhängigkeiten und Entwicklungsabhängigkeiten. Reguläre Abhängigkeiten braucht dein Code immer, unabhängig von der Umgebung, in der er läuft. Entwicklungsabhängigkeiten werden nur während der Entwicklung gebraucht, etwa Test-Frameworks wie PHPUnit, statische Analysewerkzeuge wie Psalm oder Code-Style-Prüfer wie PHP Code Sniffer. Für den Betrieb in Produktion sind sie überflüssig. Fügst du eine Abhängigkeit mit composer require [package] hinzu, wird sie als „reguläre Abhängigkeit“ markiert. Mit dem Schalter --dev landet das Paket bei den „Entwicklungsabhängigkeiten“.

Beim Ausführen von composer install werden Produktions- und Entwicklungsabhängigkeiten heruntergeladen und installiert. Willst du nur die Produktionsabhängigkeiten, hängst du den Schalter --no-dev an. Genau das brauchst du normalerweise in deiner Deployment-Pipeline, denn dort sind die Entwicklungsabhängigkeiten überflüssig.

Hinweis composer install lädt und installiert nur die Entwicklungsabhängigkeiten deiner Anwendung oder deines Pakets, nicht die der Abhängigkeiten. Hängt dein Projekt also von „Paket A“ ab und hat „Paket A“ eine Entwicklungsabhängigkeit auf „Paket B“, wird nur „Paket A“ heruntergeladen und installiert.

Entwicklungsabhängigkeiten summieren sich schnell zu einer stattlichen Liste und damit zu einer Menge Dateien. Alle diese Dateien sind über den Autoloader erreichbar. Die Zahl der Produktionsabhängigkeiten klein zu halten, spart Speicherplatz, Übertragungszeit (beim Deployment) und Laufzeit. In kleinem Maßstab fallen Speicher und Übertragung kaum ins Gewicht, aber sobald deine Anwendung oder die Zahl der Beteiligten wächst, wird der Unterschied spürbar. Ein weiterer Vorteil markierter Entwicklungsabhängigkeiten: Sie landen nicht im Autoloader, den Composer bei composer install --no-dev erzeugt.

Autoloading (autoload.php)

Wie schon erwähnt, gibt Composer den Paketen Werkzeuge an die Hand, um sich per Autoloading (autoload.php) in dein PHP-Projekt einzuklinken. Damit legt ein Paket fest, wie Composer einen Klassennamen auf einen Dateinamen abbildet. Du musst deinen Code nicht länger mit require_once– und require-Aufrufen pflastern. Es genügt, vendor/autoload.php einzubinden.

Wie bei den Abhängigkeiten kennt Composer zwei Arten von Autoloadern: die regulären autoload-Direktiven und die autoload-Direktiven für die Entwicklung. Pakete halten damit ihren Umfang und ihren Autoloader klein, aber auch Anwendungen profitieren davon. In einem gut getesteten Projekt können die Dateien, die nur für Tests da sind, einen erheblichen Teil der Codebasis ausmachen. Dasselbe gilt für Entwicklungswerkzeuge wie Kommandozeilen-Tools. In Produktion brauchst du sie nicht. Sagst du dem Autoloader, dass es Dateien für den Entwicklungs-Autoloader sind, bleiben sie bei composer install --no-dev außen vor, was der Performance deiner Anwendung zugutekommt. Die Trennung zwischen regulärem und Entwicklungs-Autoloader steht in der Konfigurationsdatei composer.json:

javascript
{
"autoload": {
"psr-4": {
"MyApp\": "src/"
}
},
"autoload-dev": {
"psr-4": {
"MyApp\Tests": "tests/"
}
}
}

Für automatisch geladene Funktionen und Klassen bietet Composer weitere Werkzeuge, siehe Optimierung des Autoloaders.

Versions-Constraints

Vor Composer hat man eine bestimmte Version einer Abhängigkeit heruntergeladen und in die Versionsverwaltung gelegt. Seit Composer musst du dich nicht mehr auf eine feste Version festlegen, sondern lässt Composer entscheiden, welche Version installiert wird. Dafür stützt sich Composer stark auf das Konzept der semantischen Versionierung. Composer macht das nicht nur für deine Anwendung, sondern zieht es bis in die Abhängigkeiten durch. Pakete dürfen also eigene Abhängigkeiten haben, die wiederum eigene Abhängigkeiten haben, und so weiter. Das führt zu mehr, aber kleineren Paketen. Solche kleinen Pakete konzentrieren sich auf eine einzige Aufgabe, und diese Funktionalität liegt an genau einer Stelle.

Anders als etwa NPM erlaubt Composer nur eine Version eines Pakets pro Anwendung. Es müssen also weniger Pakete installiert werden, dafür steigt das Risiko von Versionskollisionen. Ein guter Grund, keine feste Version einer Abhängigkeit zu verlangen, sondern einen Versionsbereich. Üblich ist dafür der Caret-Operator: ^1.0. Damit ist jede Version >= 1.0.0 und < 2.0.0 erlaubt. Da semantische Versionierung gilt, hast du die benötigten Funktionen sicher und bekommst keine Breaking Changes untergeschoben.

Mit einem Bereich statt einer festen Version vermeidest du Versionskollisionen, sobald eine andere Abhängigkeit dasselbe Paket verlangt. Nimm folgende Situation an:

Die composer.json deiner Anwendung:

javascript
{
"require": {
"vendor/package": "^1.4",
"another_vendor/package": "^1.0"
}
}

Die composer.json von vendor/package:

javascript
{
"require": {
"another_vendor/package": "^1.2"
}
}

Beim Installieren der Abhängigkeiten stellt Composer fest, dass another_vendor/package mehrfach verlangt wird. Weil es sich um Constraints handelt, kann Composer daraus ableiten, dass es Version 1.1.5 nicht installieren sollte, sondern 1.2.8.

Composer kennt mehrere Constraint-Operatoren und erlaubt sogar, sie zu kombinieren. Ein Constraint wie „php“: „^7.2“ schließt PHP 8 also aus. Willst du angeben, dass PHP 7.2 und PHP 8 unterstützt werden, kombinierst du zwei Operatoren: „php“: „^7.2 || ^8.0“.

Hinweis Eine andere Möglichkeit wäre der Weg, den Symfony gegangen ist, nämlich "php": >= 7.2". Davon halte ich persönlich wenig, denn es besagt, dass der Code auch mit PHP 9 und höher läuft. Da niemand weiß, welche Breaking Changes PHP 9 mitbringt, gebe ich solche Versprechen lieber nicht, und deshalb setze ich in meinen Repositories auf "php": "^7.2 || ^8.0".

Root Package oder nicht

Nach dem Parsen einer composer.json lassen sich die Abhängigkeiten, die dein Projekt direkt auf oberster Ebene verlangt, von den übrigen unterscheiden. Root Packages sind in der Terminologie von Composer die Abhängigkeiten der obersten Ebene, die selbst wieder eigene Abhängigkeiten nachziehen.

Nimm diese composer.json:

javascript
{
"require": {
"laminas/laminas-diactoros": "^2.0"
}
}

Ein composer install auf dieser Datei zeigt, dass nicht nur laminas/laminas-diactoros heruntergeladen wird, sondern auch laminas/laminas-zendframework-bridge, psr/http-factory und psr-http-message. In diesem Beispiel ist laminas/laminas-diactoros ein Root Package und damit grundlegend für unser Projekt.

Der Unterschied zwischen einem Root Package und einem nachgezogenen Paket liegt in der Zahl der direkten Abhängigkeiten deines Projekts. Grundsätzlich gilt: weniger Root Packages bedeuten weniger Pakete und weniger Konflikte beim Aktualisieren. Weniger Pakete, weil eine neue Version eines Pakets andere Abhängigkeiten mitbringen kann, um die sich Composer automatisch kümmert. Weniger Konflikte, weil dein Abhängigkeitsbaum weniger Constraints enthält.

Aber was qualifiziert ein Paket dafür, Root Package zu sein?

code
{
"require": {
"laminas/laminas-diactoros": "^2.0"
}
}

Sieh dir das Beispiel an, in dem wir allein auf laminas/laminas-diactoros setzen. Diactoros liefert eine Implementierung der Interfaces aus psr-http-message und psr/http-factory. Spreche ich in meinem Code ausschließlich Objekte aus laminas/laminas-diactoros an, ist diese Composer-Konfiguration in Ordnung. Beziehe ich mich dagegen auf die Interfaces aus psr-http-message und psr/http-factory – aus meiner Sicht die bessere Praxis –, dann ist das ein Grund, psr-http-message und psr/http-factory zu Root Packages unseres Projekts zu machen.

js
{
"require": {
"laminas/laminas-diactoros": "^2.0",
"psr-http-message": "^1.0",
"psr/http-factory": "^1.0"
}
}

Die Zielumgebung angeben

Du baust deine Anwendung oder dein Paket auf einer bestimmten PHP-Version. Und nur für diese PHP-Version kannst du garantieren, dass dein Code sauber läuft. Vielleicht braucht er zusätzlich eine PHP-Extension. Deshalb ist es gute Praxis, festzuhalten, welche PHP-Versionen erforderlich oder unterstützt sind und welche Extensions gebraucht werden.

Beim Bau eines Pakets lassen sich PHP-Versionen und PHP-Extensions behandeln, als wären sie gewöhnliche Pakete:

javascript
{
"require": {
"php": "^7.2",
"ext-curl": "*"
}
}

Für die cURL-Extension dürfen wir den Platzhalter * verwenden, weil die Extension an PHP gebunden ist und die Version deshalb nicht angegeben werden muss.

Denselben Ansatz kannst du beim Bau einer Anwendung nutzen und diese Einschränkungen zusätzlich als Plattform-Konfiguration hinterlegen:

javascript
{
"config": {
"platform": {
"php": "7.4.4",
"ext-curl": "*"
}
}
}

Der Vorteil: Composer rechnet mit PHP 7.4.4, auch wenn auf dem System, das die Composer-Befehle ausführt, eine andere Version installiert ist. Mit dem Trend zu Virtualisierungslösungen wie Docker und Vagrant lassen sich die Composer-Befehle so lokal ausführen, während die Anwendung in der virtualisierten Umgebung läuft. Es verhindert außerdem, dass ein Kollege mit einer brandaktuellen PHP-Version Abhängigkeiten in Versionen installiert, die die Produktionsumgebung nicht unterstützt.

Arbeiten mit der Versionsverwaltung (Git, Mercurial, Subversion)

In deinem Projekt legt Composer drei Dinge auf dem Dateisystem an: zwei Dateien namens composer.json und composer.lock sowie einen Ordner namens vendor*. Es müssen aber nicht alle drei in die Versionsverwaltung.

Die Datei composer.json enthält alle verlangten Pakete samt ihren Versions-Constraints – siehe den Abschnitt „Versions-Constraints“. Diese Datei gehört immer in die Versionsverwaltung. Ohne sie weiß niemand, der deinen Code benutzen will, welche Pakete nötig sind.

Die Datei composer.lock enthält die exakten Versionen samt Prüfsummen aller Pakete. Ist sie beim Ausführen von composer install vorhanden, installiert Composer genau die in composer.lock festgehaltenen Versionen. So sind die Abhängigkeiten in allen Umgebungen identisch. Baust du eine Anwendung, gehört diese Datei in die Versionsverwaltung. Der Hauptgrund: Anwendungen (oder andere Pakete), die dein Paket verwenden, ziehen diese Datei nicht heran, deine lokale Entwicklungsumgebung dagegen schon. Sonst entwickelst und testest du womöglich gegen veraltete Versionen deiner Abhängigkeiten, während die Nutzer deines Pakets längst die aktuellen einsetzen.

Der Ordner vendor enthält den Code der Pakete. Sein Inhalt gehört nicht in die Versionsverwaltung. Nutzt du Git, trägst du ihn in eine .gitignore ein, damit er nicht versioniert wird.

* Name und Ort dieses Ordners kannst du über die Konfigurationsoption vendor-dir ändern, für diesen Artikel bleibe ich beim Standardwert.

Abhängigkeiten ändern

Angenommen, du willst eine Abhängigkeit deiner Anwendung aktualisieren. Oder gleich mehrere. Du setzt einige composer update ab und siehst, wie sich composer.lock verändert. Du legst einen Commit an. Noch ein Schwung Updates, noch ein Commit. Ein paar Tage später machst du weiter. Das Repository ist sehr aktiv, also rebast du zuerst, damit deine Änderungen zum aktuellen Stand des Codes passen. Merge-Konflikt! Ein Konflikt in composer.json lässt sich lösen wie jeder andere, ein Konflikt in composer.lock ist deutlich unangenehmer.

Merge-Konflikte in einer composer.lock löst du, indem du die eingehenden Änderungen übernimmst – das sind die neuesten – und anschließend die Composer-Befehle erneut ausführst, mit denen du deine eigenen Änderungen ursprünglich erzeugt hast. So landen sie auf dem aktuellen Stand deines Codes. Committe die geänderte composer.lock und setze das Rebase fort. Hast du in einem weiteren Commit noch mehr an Composer gearbeitet, wiederholst du diese Schritte.

Fazit

Composer ist aus der PHP-Entwicklung nicht mehr wegzudenken. Wie jeder Paketmanager verträgt er ein paar Optimierungen. Ich benutze Composer täglich in mehreren Repositories und habe mir dabei Praktiken angewöhnt, die ich für gute Praxis halte:

  • Trenne deine Abhängigkeiten in Produktions- und Entwicklungsabhängigkeiten, um die Anwendung schlank zu halten.
  • Trenne deinen Autoloader in Produktions- und Entwicklungs-Autoloader, um ihn klein zu halten und die Performance zu steigern.
  • Setze nicht auf eine feste Version einer Abhängigkeit, sondern auf einen Bereich, damit ein Upgrade weniger wehtut.
  • Hänge direkt nur von den Abhängigkeiten ab, die du in deiner Anwendung wirklich aufrufst, damit ein Upgrade oder der Wechsel auf eine Alternative einfach bleibt.
  • Immer composer.json in die Versionsverwaltung, composer.lock dagegen nur bei Anwendungen, damit Nutzer deines Pakets andere Pakete weiterhin aktualisieren können.
  • Halte die Änderungen an composer.json und composer.lock nach Möglichkeit in einem einzigen Commit pro Pull- oder Merge-Request, um Merge-Konflikte in diesen Dateien zu minimieren.

PHP

Damien Flandrin Webentwickler seit 2010, Gründer von Gekkode und Email Impact. Jeder Artikel wird vor der Veröffentlichung an einem echten Projekt getestet. Kontakt
Newsletter

Neue Tests, Tutorials und Projekte, per E-Mail.

Reproduzierbare Tests, versionierter Code, datierte Ergebnisse. Niemals Spam.