The quote DTO on the booking platform has been rewritten twice in fourteen months. Once in January 2022, when PHP 8.1 gave us readonly on properties and I put the keyword in front of all nine promoted constructor parameters. Once a fortnight ago, when the platform went to PHP 8.2.2 and I deleted those nine keywords and put one in front of the class. The second rewrite took eleven minutes and removed 34 characters. It also broke a method I wrote myself, in a way the RFC had warned me about a year earlier.
What the class-level keyword actually does
PHP 8.2 landed on 8 December 2022 with readonly classes in it. Marking a class readonly applies the modifier to every declared instance property and, separately, prevents the creation of dynamic properties on that class. The RFC is short and the restrictions follow from the 8.1 feature rather than being new: every property still needs a type, so public $bar; inside a readonly class is a fatal Readonly property Foo::$bar must have type; static properties are refused outright; and inheritance has to match in both directions, a non readonly class cannot extend a readonly one and a readonly class cannot extend a non readonly one.
The one genuinely new interaction is with the dynamic property escape hatch. You cannot opt a readonly class back into dynamic properties: #[AllowDynamicProperties] on a readonly class is a compile time error, Cannot apply #[AllowDynamicProperties] to readonly class Foo, which is documented in the manual too. That is the right call and it also means the two features cannot be reasoned about separately, which caught me during review of a different class.
The clone that does not work
Our DTO had a withCurrency() written the lazy way, the three lines that are commented out below, because when the properties were plain and mutable in 2021 the lazy way was fine:
<?php
namespace AppBookingDto;
readonly class ShipmentQuote
{
public function __construct(
public string $reference,
public int $amountCentavos,
public string $currency,
) {}
public function withCurrency(string $currency): self
{
// What we had, and what PHP now says about it:
//
// $clone = clone $this;
// $clone->currency = $currency;
// return $clone;
//
// PHP Fatal error: Uncaught Error: Cannot modify readonly
// property AppBookingDtoShipmentQuote::$currency
return new self(
$this->reference,
$this->amountCentavos,
$currency,
);
}
}
The error is Cannot modify readonly property AppBookingDtoShipmentQuote::$currency, thrown as an Error, and it fires on the assignment inside the method even though the object being written to is a fresh clone that nobody else has seen yet. The readonly properties RFC spells this out in its rationale, with a Point class and a withX method, and says it is by design: the cloned property is already initialised, so writing to it is a post initialisation modification, and the fact that the modification is temporary is irrelevant. The RFC also notes that a future “clone with” construct, which would assign the new value during cloning instead of copying the old one first, would make the clone shape legal. It does not exist today.
So every wither on an immutable DTO builds a new instance through the constructor. For three properties that is fine. For the nine property quote it is an ugly positional argument list, and named arguments help the reader without helping the writer, because you still have to list every property you are not changing. That is the cost of the keyword and I still think it is worth paying: the class is now 41 lines and there is exactly one place where any field is ever set.
The deprecation that produced the noise
Readonly classes were the feature I wanted. Dynamic properties were the change that filled the logs. From 8.2, creating a property that was never declared emits Deprecated: Creation of dynamic property Legacy::$extra is deprecated, and the oldest module on the platform, a pricing importer written against PHP 5.6 conventions and ported forward twice, produced 1,240 of those lines in one nightly run.
The manual gives three remedies: declare the property, which is what we did in 38 of 41 cases; add #[AllowDynamicProperties] to the class, which also applies to every child class, so it is a wider promise than it looks; or use a WeakMap when you are attaching data to an object you do not own. stdClass still allows dynamic properties, and __get and __set are untouched, so the objects we build from json_decode and the ones with real magic methods were never the problem. The three classes we could not fix cheaply got the attribute and a comment with a ticket number.
Two other deprecations in the same release cost small amounts of time: utf8_encode() and utf8_decode() are deprecated, and the "${var}" interpolation style is deprecated in favour of "{$var}". The second one is a find and replace. The first one is not, because the functions were never doing what the two call sites assumed they were doing.
What we have not used yet
The rest of 8.2 is sitting there mostly untouched. Disjunctive normal form types let you combine intersections and unions, written as (HasId&Timestamped)|null, and we have exactly one signature where that is the honest type instead of a doc block plus a runtime check. null and false became stand alone types and true was added, which matters most for narrowing return types on legacy functions that return false on failure. Constants in traits work now, and that is the one I keep reaching for and then not using, because a constant in a trait still reads to me like a constant looking for a class.
Next on this codebase: PHPStan is at level 6 on the platform and the dynamic property fixes pushed 14 previously invisible property writes into view. I am not convinced all 38 declared properties should exist, and a few of them are almost certainly dead. Finding out means reading the importer, which is the work nobody has volunteered for since 2021.
Sources
- PHP 8.2.0 release announcement: the feature list for the release, including readonly classes, DNF types, the new stand alone types, constants in traits and the dynamic property deprecation.
- PHP RFC: Readonly classes: what the class modifier applies to, the typed and static property restrictions, the inheritance rules, and the
#[AllowDynamicProperties]compile time error. - PHP RFC: Readonly properties 2.0: the
Cannot modify readonly propertyerror, the rationale section showing the clone based wither failing, and the note about a possible future “clone with”. - Manual: readonly classes: the fatal error texts as the documentation states them.
- PHP 8.2 deprecated features: the dynamic property deprecation and its three documented remedies, the
stdClassand magic method exemptions, and theutf8_encodeand"${var}"deprecations. - PHP 8.2 new features: DNF types,
null,falseandtrueas types, and constants in traits.