Der Wechsel auf eine neue Major Version von PHPUnit ist eine Änderung an einer Zeile in deiner composer.json. Wenn er das bei dir nicht ist, liegt das nicht am Upgrade. Es liegt an dem Jahr davor.
Ich sehe beide Sorten von Projekten: in Schulungen und in der Beratung, wenn ich mit einem Team an seiner Testsuite arbeite, und in Gesprächen mit Entwicklerinnen und Entwicklern auf Konferenzen und Meetups. Die einen aktualisieren im Februar und sind am Nachmittag fertig. Die anderen hängen drei Major Versions zurück und behandeln das Upgrade als Projekt mit Budget, eigenem Branch und eigenem Namen. Beide werden von kompetenten Leuten betreut, und über ein paar Jahre gerechnet ist der Aufwand ungefähr derselbe. Was sich unterscheidet, ist der Zeitpunkt. Die einen zahlen laufend, die anderen auf einmal und mit Zinsen.
Diesen Artikel habe ich schon einmal geschrieben, im Februar 2019, kurz nachdem PHPUnit 8 erschienen war und einige Entwicklerinnen und Entwickler in sozialen Medien deutlich gemacht hatten, was sie von den Änderungen hielten. Er heißt Hilfe! Meine Tests funktionieren nicht mehr und ist bis heute gültig. Deshalb steht in dem Text hier kein Wort darüber, was eine bestimmte Version hinzugefügt oder entfernt hat. Der Mechanismus darunter hat sich in sieben Jahren nicht geändert, und er wird auch die Version überleben, auf der du gerade bist.
Der Releasezyklus ist eine Zusage
PHPUnit bekommt jedes Jahr am ersten Freitag im Februar eine neue Major Version. Dazwischen erscheinen Minor Versions am ersten Freitag im April, Juni, August, Oktober und Dezember. Patch Releases kommen, wenn sie gebraucht werden.
Dieser Kalender ist keine Trivia. Er sagt dir, welche Art von Änderung dich zu welchem Zeitpunkt überhaupt erreichen kann. Ein Patch Release behebt Fehler und ändert kein Verhalten. Eine Minor Version ergänzt Funktionalität rückwärtskompatibel und macht deine Testsuite nicht kaputt. Nur eine Major Version entfernt etwas.
Elf Monate im Jahr hat die Frage „Bricht mir das Update von PHPUnit den Build?“ damit eine langweilige Antwort. Der zwölfte Monat ist ein Datum, das du drei Jahre im Voraus in den Kalender eintragen kannst.
Nichts verschwindet überraschend
Ein Feature, das PHPUnit verlässt, durchläuft drei Stufen, und zwischen zwei Stufen liegt immer eine Major Version.
Eine Soft Deprecation markiert die betroffene API als @deprecated. Zur Laufzeit passiert nichts. Deine IDE streicht den Aufruf durch, deine statische Analyse meldet ihn, deine Testsuite bemerkt ihn nicht.
Die Hard Deprecation in der nächsten Major Version macht daraus eine Meldung während der Testausführung. Das Feature funktioniert dabei genau wie vorher. PHPUnit sagt dir etwas, es tut dir nichts.
Entfernt wird erst in der Major Version danach.
Der any() Matcher läuft gerade durch diesen Ablauf. PHPUnit 12.5 hat ihn soft-deprecated, PHPUnit 13 hat ihn im Februar 2026 hard-deprecated, und PHPUnit 14 wird ihn im Februar 2027 entfernen. Warum er weg muss und wie die Umstellung aussieht, habe ich in Vom Anti-Pattern zur Klarheit aufgeschrieben. Zwischen der ersten Meldung im Testlauf und dem Verschwinden liegt ein volles Jahr, zwischen der Soft Deprecation und dem Verschwinden noch länger.
Die Deprecations in deiner Testausgabe sind das Changelog deines nächsten Upgrades, ein Jahr im Voraus geliefert, zeilenweise. In den meisten Projekten liest sie niemand.
Die Zahl steht schon auf deinem Bildschirm
An dieser Stelle geht in vielen Projekten etwas schief, und die Ursache ist leicht zu übersehen. PHPUnit unterscheidet zwei Arten von Deprecations, und für ein Upgrade von PHPUnit ist nur eine davon relevant.
--display-deprecations zeigt dir Deprecations, die dein Code auslöst: E_DEPRECATED aus PHP selbst, E_USER_DEPRECATED aus deinen Abhängigkeiten. Das ist die Liste für dein nächstes PHP-Upgrade. --display-phpunit-deprecations zeigt dir die Deprecations von PHPUnits eigener API. Das ist die Liste für dein nächstes PHPUnit-Upgrade. Diese Option gibt es seit PHPUnit 10.5.32 vom September 2024. Wer die erste Option gesetzt hat und sich über eine leere Liste freut, hat die falsche Frage gestellt.
Die Zahl selbst musst du nicht anfordern. Sobald eine PHPUnit-Deprecation ausgelöst wurde, schreibt PHPUnit am Ende des Testlaufs „OK, but there were issues!“ und darunter eine Zeile „PHPUnit Deprecations: n“. Was die Option hinzufügt, ist die Liste dahinter: welcher Test, welche Zeile, welches Feature.
Wenn dir diese Zeile noch nie aufgefallen ist, gibt es dafür zwei mögliche Gründe. Entweder ist deine Testsuite sauber. Oder in der CI zählt nur der Exit Code, und die letzten fünf Zeilen der Ausgabe liest niemand.
Die Reihenfolge ist der ganze Trick
Sechs Schritte. Wichtiger als die Liste ist die Frage, an welcher Stelle in ihr die Änderung an der composer.json steht.
- Geh auf das aktuellste Patch Release deiner jetzigen Major Version
- Lass deine Testsuite mit
--display-phpunit-deprecationslaufen - Behebe jede gemeldete Deprecation
- Aktualisiere PHP, falls die nächste Major Version von PHPUnit eine Version verlangt, auf der du noch nicht bist
- Ändere das Version Constraint in der
composer.json - Lies den ChangeLog und die Release-Ankündigung
Vier der sechs Schritte passieren vor der Änderung am Constraint, auf einer Version, auf der die alte und die neue API beide existieren. Genau darauf kommt es an. Du räumst eine Deprecation nach der anderen weg, und nach jeder einzelnen ist deine Testsuite grün. Du kannst jederzeit aufhören, und du kannst jederzeit ausliefern. Nichts ist zwischendurch halb migriert.
Der übliche Weg dreht die Reihenfolge um. Erst das Constraint ändern, dann in einem Branch sitzen, in dem nichts läuft, bis alles behoben ist. Die Fehlerliste, die dort auf dich wartet, ist exakt so lang wie die Liste der Deprecations, die ein Jahr lang niemand gelesen hat. Es ist dieselbe Arbeit in einer Form, in der du nicht anhalten kannst.
Schritt 4 verdient eine Anmerkung, weil er als einziger nicht in deiner CI stattfindet. Eine neue Major Version von PHPUnit verlangt manchmal eine neuere PHP-Version, und das Upgrade von PHP betrifft die Produktion. Es braucht ein Deployment-Fenster und einen Rollback-Plan. Fang deshalb früh damit an, parallel zu den Schritten 1 bis 3. PHPUnit verlangt dabei keine PHP-Version, die PHP nicht längst selbst von dir verlangt hätte: PHPUnit 13 setzt PHP 8.4 voraus und ist im Februar 2026 erschienen, fünf Wochen nachdem der aktive Support für PHP 8.3 am 31. Dezember 2025 ausgelaufen war. Wenn ausgerechnet PHPUnit das PHP-Upgrade auf deine Roadmap bringt, hat es ein Gespräch erzwungen, das du dir ohnehin schuldig warst.
Schritt 6 ist kein Füllmaterial. Lies die Release-Ankündigung nach dem Upgrade, wenn du fünf ruhige Minuten hast. Dort steht, was du gerade dazubekommen hast. In einem Jahr sind es genau die Funktionen, die du nicht bemerkt hast, deren Fehlen dich stört.
Die Ratsche
Alles bisher beschreibt ein einzelnes Upgrade. Die Teams, für die der Februar eine Formalität ist, tun eine Sache zusätzlich: Sie lassen Deprecations gar nicht erst auflaufen.
Der Mechanismus dafür ist eine Ratsche. Lass --display-phpunit-deprecations in der CI mitlaufen, damit die Meldungen sichtbar sind statt begraben. Bring die Zahl auf null. Wenn das in diesem Quartal nicht realistisch ist, bring sie auf irgendeine Zahl und lass sie nie wieder steigen. Sobald du bei null bist, setzt du failOnPhpunitDeprecation="true" in deiner phpunit.xml, und ab da hält die CI den Stand für dich.
Eine Abkürzung über die Baseline gibt es nicht. --generate-baseline erfasst Deprecations, Notices und Warnings, die dein Code auslöst, nicht die Deprecations von PHPUnits eigener API. Das ist Absicht. Eine Baseline ist für Probleme gedacht, mit denen du dauerhaft leben musst, weil sie aus Code kommen, der dir nicht gehört. Eine PHPUnit-Deprecation ist etwas anderes: Sie steht in deinem Testcode, und ihr Ablaufdatum steht im Kalender.
Diese Ratsche hat einen Nebeneffekt, der selten benannt wird. Wer im Februar aktuell ist, bekommt Fehlerbehebungen und Verbesserungen, während sie frisch sind, und meldet Probleme, während ich den Kontext noch im Kopf habe. Rückmeldungen aus einem Projekt, das auf der aktuellen Version arbeitet, ändern etwas an dem, was als Nächstes kommt. Rückmeldungen zu einer Version, die vor drei Jahren erschienen ist, ändern daran nichts mehr.
Warum ich dir diese Arbeit mache
Bis hierhin klingt das nach einem Vorgang, für den es nur Disziplin braucht. Die andere Seite will ich nicht auslassen. Jedes Feature, das ich entferne, erzeugt Arbeit in fremden Projekten. Ich bekomme die Rechnung nicht, du bekommst sie.
Ich mache es trotzdem, weil die Alternative teurer ist und nur unsichtbar bleibt. Ein Testframework, das nach fünfundzwanzig Jahren jede API behält, die es je hatte, bietet für jede Aufgabe drei Wege an, von denen zwei falsch sind. Wer neu ins Team kommt, wählt dann mit derselben Wahrscheinlichkeit den falschen wie den richtigen. Diese Kosten verbucht niemand als Aufwand, weil sie sich nicht an einem Nachmittag als Migration zeigen, sondern über Jahre als schlechtere Tests.
Der Handel geht deshalb nicht in jedem Projekt auf, und ich behaupte das auch nicht. Es gibt Codebasen, deren Testsuite seit Jahren unverändert läuft und an denen niemand mehr etwas ändert. Dort ist Stehenbleiben die richtige Entscheidung. Alte PHPUnit-Versionen hören nicht auf zu funktionieren, sie werden nur nicht mehr repariert. Bugfix Support für eine Major Version endet zwei Jahre nach ihrem Erscheinen, an dem Tag, an dem die übernächste erscheint. Der Unterschied zwischen „grün“ und „unterstützt“ fällt genau dann auf, wenn du auf einen Fehler triffst, der vor zwei Jahren behoben wurde, in einer Version, die du ohne zweiwöchige Migration nicht erreichst.
Fazit
Das Upgrade ist nicht der schwierige Teil. Der schwierige Teil ist, sich ein Jahr lang eines daraus zu machen.
Am ersten Freitag im Februar 2027 erscheint PHPUnit 14. Was darin entfernt wird, meldet dir PHPUnit 13 bei jedem Testlauf, mit --display-phpunit-deprecations auch mit Testname und Zeilennummer. Bis dahin sind es drei Monate.