Jedes PHP-Release bringt ein Changelog mit, das länger ist, als es jemand liest, und der größte Teil davon erreicht nie den Code, den man an einem Dienstag schreibt. Die interessante Frage ist nie “was ist neu”, sondern “was verändert eine Entscheidung, die man ohnehin gerade trifft”. PHP 8.5 hat eine Handvoll Features, die genau das tun, und einen Stapel Deprecations, die zwei Versionen später still zu fatalen Fehlern werden, wenn man sie ignoriert.
Das ist es, wonach wir in PHP 8.5 in echten Symfony-Codebasen tatsächlich greifen, was wir liegen lassen, und die kurze Liste an Deprecations, die es wert sind, behoben zu werden, bevor sie zum Incident von jemandem werden. Es ist kein Changelog. Es ist eine Reihe von Entscheidungen.
Clone with: das Feature, auf das immutabler Code gewartet hat
Wer Value Objects so baut wie wir, readonly Klassen mit benannten Konstruktoren, hat diese Methode öfter geschrieben, als er zählen kann:
final readonly class Money
{
public function __construct(
public int $cents,
public string $currency,
) {
}
public function withCents(int $cents): self
{
return new self($cents, $this->currency);
}
}
Jede with*-Methode hat dieselbe Form: eine ganz neue Instanz bauen, jede Property außer der geänderten kopieren, und hoffen, dass niemand eine vierte Property ergänzt und vergisst, sie durch alle sechs Kopien zu fädeln. Genau dieser letzte Punkt ist ein echter Bug, und er ist im Review unsichtbar, weil der Code vollständig aussieht.
PHP 8.5 gibt readonly Objekten einen Ausweg. clone with kopiert das Objekt und überschreibt benannte Properties in einem einzigen Ausdruck:
public function withCents(int $cents): self
{
return clone($this, ['cents' => $cents]);
}
Das Überschreiben passiert während des Klonens, bevor der readonly Schutz zuschnappt, deshalb ist es erlaubt, eine readonly Property genau dieses eine Mal neu zu schreiben und kein weiteres. Ergänzt man eine vierte Property im Konstruktor, muss keine der with*-Methoden angefasst werden, weil sie nur die Property benennen, die sie ändern. Das ist der ganze Punkt: die Kopie ist nicht länger deine Verantwortung.
Wir behandeln das als neuen Standard für Mutatoren von Value Objects. Es beseitigt genau die Klasse von Bug, eine vergessene Property in einer handgeschriebenen Kopie, die die statische Analyse nicht immer fängt.
Der Pipe-Operator: echt, aber schmaler als die Demos
Der Pipe-Operator, |>, ist das Aushängeschild-Feature, und die Demos versprechen zu viel. Was er tut, ist klein und ehrlich: er nimmt den Wert auf der linken Seite und übergibt ihn als einziges Argument an das Callable auf der rechten Seite.
$slug = $title
|> trim(...)
|> strtolower(...)
|> (fn (string $value): string => \str_replace(' ', '-', $value));
Von oben nach unten gelesen heißt das “nimm den Titel, trimme ihn, mach ihn klein, ersetze Leerzeichen durch Bindestriche”. Die Variante, die er ablöst, ist die, die alle hassen: entweder tief verschachtelte Aufrufe, die man von innen nach außen liest, oder eine Leiter aus temporären Variablen, die nur existieren, um an die nächste Zeile übergeben zu werden.
Die entscheidende Einschränkung: jeder Schritt muss ein Callable sein, das genau ein Argument nimmt. strtolower(...) passt. Alles, was ein zweites Argument braucht, passt nicht, also verpackt man es in eine Arrow-Funktion, wie bei str_replace oben. Genau an dieser Verpackung leckt die Eleganz, und deshalb passt der Operator gut zu echten Transformationsketten mit einem Argument und schlecht zu dem meisten, was man durch ihn zu zwängen versucht.
Wir setzen ihn dort ein, wo die Daten wirklich eine Pipeline sind: das Normalisieren einer Eingabe, das Transformieren eines Strings, das Durchreichen eines Werts durch ein paar reine Funktionen. Wir nutzen ihn nicht, um gewöhnliche Geschäftslogik clever aussehen zu lassen. Wenn ein Leser innehalten und den Ablauf neu parsen muss, waren die verschachtelten Aufrufe ehrlicher.
NoDiscard: den still verworfenen Rückgabewert fangen
Hier ist ein Bug, der Teams echte Zeit kostet. Eine Methode eines Value Objects gibt eine neue Instanz zurück, und ein Aufrufer schreibt:
$money->withCents(500); // tut nichts, sieht aber so aus, als täte es etwas
Weil das Objekt immutabel ist, ist der Rückgabewert der ganze Sinn, und ihn zu verwerfen ist immer ein Fehler. Bis 8.5 hat einen nichts darauf hingewiesen. Das Attribut #[\NoDiscard] tut es:
#[\NoDiscard('die zurückgegebene Instanz ist das Ergebnis; das Original bleibt unverändert')]
public function withCents(int $cents): self
{
return clone($this, ['cents' => $cents]);
}
Ruft man sie auf und verwirft das Ergebnis, gibt PHP eine Warnung aus, die auf die genaue Zeile zeigt. In den seltenen Fällen, in denen man den Wert wirklich verwerfen will, sagt man das mit einem (void)-Cast, der die Absicht dokumentiert, statt sie zu verstecken.
Die natürlichen Orte dafür sind jede immutable with*-Methode und jede Funktion, deren einzige Aufgabe es ist, etwas zurückzugeben, auf das der Aufrufer reagieren muss: ein Validierungsergebnis, ein Command-Objekt, ein Builder-Schritt. Es ist eine einzeilige Annotation, die eine ganze Kategorie stiller No-Ops in eine sichtbare Warnung verwandelt, an der die CI scheitern kann.
Closures in konstanten Ausdrücken: Konfiguration ohne Factory
Vor 8.5 durfte ein Closure nicht dort stehen, wo PHP einen konstanten Ausdruck erwartet: ein Property-Default, eine Klassenkonstante, ein Attribut-Argument. Das erzwang jedes Mal ein wenig Zeremonie, wenn man Verhalten an eine Deklaration hängen wollte, meist eine Factory-Methode oder ein match-Zweig irgendwo anders in der Datei.
Jetzt ist ein statischer Closure oder ein First-Class-Callable ein gültiger konstanter Ausdruck:
final class RetryPolicy
{
public const array BACKOFF = [
'linear' => static fn (int $attempt): int => $attempt * 1000,
'exponential' => static fn (int $attempt): int => 2 ** $attempt * 1000,
];
}
Die Strategie steht neben dem, was sie konfiguriert, statt in einer switch-Anweisung drei Dateien weiter. Das ist ein leises Feature, das die Architektur nicht verändert, aber es beseitigt eine wiederkehrende Reiberei in genau dem deklarativen, attributgetriebenen Stil, zu dem Symfony einen drängt.
Die kleinen ergonomischen Gewinne
Zwei Änderungen sind zu klein für einen eigenen Abschnitt und zu nützlich, um sie auszulassen.
array_first() und array_last() beenden endlich den Tanz mit reset() und end(). Beide bewegen den internen Zeiger des Arrays, was ein Seiteneffekt ist, den niemand will, wenn er nur einen Wert liest, und die Alternativen, $array[array_key_first($array)], sind Rauschen. Die neuen Funktionen lesen den ersten oder letzten Wert und geben bei einem leeren Array null zurück, ganz ohne Zeigerspiele:
$latest = array_last($orderedResults); // null wenn leer, kein Seiteneffekt
final bei Konstruktor-promoteten Properties schließt eine echte Lücke. Man konnte eine Property bereits im Konstruktor promoten, und man konnte eine Property bereits als final markieren, aber nicht beides an einer Stelle. Jetzt geht das, also sagt eine Property, die in einer Unterklasse nicht überschrieben werden darf, das genau dort, wo sie deklariert wird:
public function __construct(
public final UserId $id,
) {
}
Keine dieser Änderungen verändert ein Design. Beide beseitigen eine kleine, tägliche Reiberei, und kleine tägliche Reibereien sind das meiste von dem, was das Arbeiten in einer Codebasis ermüdend macht.
Die Deprecations, die dieses Quartal behoben gehören
Die Features sind optional. Die Deprecations sind ein Fahrplan. Jede der folgenden ist in 8.5 eine Warnung und in einem künftigen Major ein fataler Fehler, deshalb ist der billige Zeitpunkt zum Beheben jetzt, solange sie ein grep und kein Ausfall sind.
- Nicht kanonische Cast-Namen.
(boolean),(integer),(double)und(binary)sind zugunsten von(bool),(int),(float)und(string)deprecated. Das ist ein reines Suchen und Ersetzen, und es gibt keinen Grund, warum es je als Warnung die Produktion erreichen sollte. - Der Backtick-Operator. Backticks als Alias für
shell_exec()sind deprecated. Wer sie hat, hat mit ihnen fast sicher die am wenigsten geprüfte und gefährlichste Zeile der Datei, also tut die Deprecation einen Gefallen. Ersetze sie durch die Symfony-Process-Komponente, die ohnehin schon da ist. nullals Array-Offset.nullals Schlüssel zu verwenden, auch inarray_key_exists(), ist zugunsten eines expliziten leeren Strings deprecated. Das versteckt sich in Code, der sich auf loose typing verlässt, also lohnt sich hier ein Durchlauf der statischen Analyse statt eines manuellen Lesens.__sleep()und__wakeup(). Sanft deprecated zugunsten von__serialize()und__unserialize(). Nicht dringend, aber wenn man ohnehin eine Klasse anfasst, die das alte Paar noch nutzt, migriert man es gleich mit.
Keine davon ist schwer. Der Fehlerfall ist nicht die Schwierigkeit, sondern dass sie unbemerkt liegen bleiben, bis ein Versionssprung eine Warnung in einen Absturz verwandelt, und dann sind sie zum denkbar schlechtesten Zeitpunkt dringend. Eine Codebasis auf PHPStan Level 8 mit aktivierten Deprecation-Regeln bringt sie alle in einem Durchlauf ans Licht.
Was wir heute einschalten würden
Die Kurzfassung. clone with ist der neue Standard für Mutatoren von Value Objects, und es lohnt sich ein kleines Refactoring, um es in den bestehenden zu übernehmen. #[\NoDiscard] gehört auf jede immutable Methode und jede Funktion, die ein Ergebnis zurückgibt, das der Aufrufer nutzen muss, und man will es dort haben, bevor der nächste stille No-Op ausgeliefert wird. Der Pipe-Operator verdient seinen Platz in echten Transformations-Pipelines und sonst nirgends. Closures in Konstanten, array_first und array_last sowie final bei promoteten Properties sind kostenlose ergonomische Gewinne, die man übernimmt, während man den Code ohnehin anfasst.
Und die Deprecations werden dieses Quartal beseitigt, nicht in der Woche vor dem 9.0-Upgrade, denn das ist der Unterschied zwischen einem grep und einem Todesmarsch.
Wenn deine Codebasis ein paar PHP-Versionen zurückliegt und das Upgrade im Backlog immer weiter nach unten rutscht, ist das genau die Arbeit, für die unser Technical-Debt-Engagement gebaut ist. Wir kartieren, was ein Versionssprung tatsächlich berührt, beseitigen die Deprecations, die zu fatalen Fehlern werden, und hinterlassen dich auf einem Stand, auf dem das nächste Upgrade ein routinierter Nachmittag statt eines quartalslangen Projekts ist.
Referenzen
- PHP 8.5 Release-Ankündigung : die offizielle Feature-Liste, inklusive clone with, dem Pipe-Operator und dem vollständigen Satz der hier genannten Deprecations.
- Clone with RFC : Design und Semantik von
clone with, inklusive der Begründung, warum das Überschreiben bei readonly Properties während des Klonens erlaubt ist. - Pipe operator RFC : die Motivation und die Einschränkung auf Callables mit einem Argument, die prägt, wo der Operator passt.
- NoDiscard RFC : das Attribut, die Warnung, die es ausgibt, und der
(void)-Cast, der sie bewusst unterdrückt.