On the face of it, the new readonly
properties in PHP 8.1 seem really useful for Value Objects and Data Transfer Objects: both should be immutable; setting property values should always be handled in the constructor, so no setters; and with readonly properties, we no longer need to code boilerplate getter methods; so we only need to code a comparison method for value objects. Between constructor property promotion introduced in PHP 8, and readonly
properties introduced in PHP 8.1, we can reduce the code (skipping the comparison for brevity) for defining a Money
class from
class Money {
protected int $value;
protected Currency $currency;
public function __construct(int $value, Currency $currency)
{
$this->value = $value;
$this->currency = $currency;
}
public function getValue(): int {
return $this->value;
}
public function getCurrency(): Currency {
return $this->currency;
}
}
to a much more simplistic
class Money {
public function __construct(
public readonly int $value,
public readonly Currency $currency
) {}
}
and then we directly access the value and currency of the object externally as object properties, rather than needing to use the getter methods of the original version.
The new version of our Money
class looks so much cleaner: we’ve reduced the cognitive overhead, and we’ve eliminated a lot of boilerplate code – readonly
properties FTW, right?
But used like this, readonly
properties create a problem if we need to clone the Money
object, with a modified value. As an example, let’s extend our old version of the Money
class to create a couple of new classes so that we can make a Payment that gives us a new Balance:
class Payment extends Money {
}
class Balance extends Money {
public function makePayment(Payment $payment): Balance {
if ($payment->currency->code !== $this->currency->code) {
throw new Exception(sprintf('Payment must be in %s', $this->currency->name));
}
$newBalance = clone $this;
$newBalance->value = $this->value + $payment->value;
return $newBalance;
}
}
> Note that we should probably create a deep clone in this case, creating a new instance of Currency
as well, but we’ll look at deep clones in a while. For the moment, I want to keep things simple.
This use of clone is a common approach when working with Value Objects: calling addPayment()
shouldn’t change the existing Balance
object, because it’s immutable, so we return a new instance of a Balance
object containing the new value. And because the clone is an instance of the same class, we can directly reference the properties within the makePayment()
method without needing to use the getters.
We’d use this new functionality to create our Balance
and Payment
objects, then we execute the makePayment()
method, which returns a new Balance
object, leaving the original instance unchanged:
$balance = new Balance(120, new Currency('GBP'));
$payment = new Payment(30, new Currency('GBP'));
$newBalance = $balance->makePayment($payment);
And that code would work perfectly if we were still using the old Money
class with protected/private properties.
However, if we try to clone the new readonly
property version of our Balance
object, and update the value property on the clone in the same way, we’ll get an error, because the value property has already been initialised during the cloning process.
Cannot modify readonly property Money::$value
The object has been successfully cloned as an identical clone; but we can’t modify any of those readonly
properties in the clone because they are readonly
.
So suddenly readonly
properties look a lot less useful because we can’t use clone with classes that use them such as Value Objects and Data Transfer Objects.
It’s an unfortunate oversight that I hope might be rectified in some way in a future PHP release, perhaps some additional magic in the __clone()
magic method, or possibly Property Accessors
if an RFC for that is ever accepted; but there are a couple of alternative approaches that can be used instead of clone to work round the issue.
Spatie.be have published a Cloneable Trait that adds a with()
method to any class that uses it. While Spatie work mainly within the Laravel ecosystem, this small Trait can be used with any framework, or vanilla PHP. Cloneable
uses Reflection to build the new clone, populating modified properties from the list provided as arguments.
class Money {
use Cloneable;
public function __construct(
public readonly int $value,
public readonly Currency $currency
) {}
}
class Payment extends Money {
}
class Balance extends Money {
public function makePayment(Payment $payment): Balance {
if ($payment->currency->code !== $this->currency->code) {
throw new Exception(sprintf('Payment must be in %s', $this->currency->name));
}
$newBalance = $this->with(value: $this->value + $payment->value);
return $newBalance;
}
}
And this will achieve exactly what we want.
I’d strongly recommend using Spatie.be’s Cloneable Trait
; but it does have some limitations.
It will only create a shallow clone, so if we wanted to create a deep clone of our Money object, we’d need to clone our Currency
as well, which also has readonly
properties, so we’re potentially making the code a bit more complex if we need deep clones.
It also only modifies the properties in the scope of a single class – note that we have to apply the Cloneable Trait
against the Money
class rather than the Balance
class in the above example, because that’s where the properties are actually defined – which can become an issue if we have additional readonly
properties defined at different levels within an inheritance tree. Suppose that we want an additional $lastTransactionDateTime
property against our Balance
object:
class Balance extends Money {
use Cloneable;
public readonly DateTimeInterface $lastTransactionDateTime;
public function __construct(
int $value,
Currency $currency,
DateTimeInterface $lastTransactionDateTime = new DateTimeImmutable(timezone: new DateTimezone('UTC'))
) {
parent::__construct($value, $currency);
$this->lastTransactionDateTime = $lastTransactionDateTime;
}
public function makePayment(Payment $payment): Balance {
if ($payment->currency->code !== $this->currency->code) {
throw new Exception(sprintf('Payment must be in %s', $this->currency->name));
}
$newBalance = $this->with(
value: $this->value + $payment->value,
lastTransactionDateTime: new DateTimeImmutable(timezone: new DateTimezone('UTC'))
);
return $newBalance;
}
}
We might expect this to work, but instead we get
Cannot initialize readonly property Money::$value from scope Balance
The Cloneable Trait
doesn’t walk the inheritance tree: We can’t set the Balance
properties from the scope of Money
, or the Money
properties from the scope of Balance
; even if we’re applying the Cloneable Trait
for both the Money
and Balance
classes.
That being said, Value Objects and DTOs are often a single level, with no inheritance. But a better approach to our Money/Balance problem would have been to use Composition instead of Inheritance, and to have a Money
object as a property of the Balance
class.
class Money {
use Cloneable;
public function __construct(
public readonly int $value,
public readonly Currency $currency
) {}
}
class Balance {
use Cloneable;
public function __construct(
public readonly Money $money,
public readonly DateTimeInterface $lastTransactionDateTime = new DateTimeImmutable(timezone: new DateTimezone('UTC'))
) {}
public function makePayment(Payment $payment): Balance {
if ($payment->currency->code !== $this->money->currency->code) {
throw new Exception(sprintf('Payment must be in %s', $this->money->currency->name));
}
$newBalance = $this->with(
money: $this->money->with(value: $this->money->value + $payment->value),
lastTransactionDateTime: new DateTimeImmutable(timezone: new DateTimezone('UTC'))
);
return $newBalance;
}
}
$balance = new Balance(new Money(120, new Currency('GBP')));
$payment = new Payment(30, new Currency('GBP'));
$newBalance = $balance->makePayment($payment);
We need to do a deep clone here to ensure that the money
property in our Balance
object is also cloned as a new Money
object with the correct value. At just two levels of Composition, the code isn’t too ugly or hard to read, although it is an additional level of complexity for setting the new Money
value.
Another strong argument for the use of Composition rather than Inheritance.
An alternative approach, if all the properties are public and set through the constructor, and are named matching the constructor’s arguments, is to cast the object to an array and then construct a new instance passing that array as constructor arguments using the spread
operator.
class Balance extends Money {
public readonly DateTimeInterface $lastTransactionDateTime;
public function __construct(
int $value,
Currency $currency,
DateTimeInterface $lastTransactionDateTime = new DateTimeImmutable(timezone: new DateTimezone('UTC'))
) {
parent::__construct($value, $currency);
$this->lastTransactionDateTime = $lastTransactionDateTime;
}
public function makePayment(Payment $payment): Balance {
if ($payment->currency->code !== $this->currency->code) {
throw new Exception(sprintf('Payment must be in %s', $this->currency->name));
}
$balanceProperties = (array) $this;
$balanceProperties['value'] = $this->value + $payment->value;
$balanceProperties['lastTransactionDateTime'] = new DateTimeImmutable(timezone: new DateTimezone('UTC'));
$newBalance = new self(...$balanceProperties);
return $newBalance;
}
}
When we cast the object to an array, PHP creates an associative array using the property names as keys; and since PHP 8.1 supports using the spread
operator with associative arrays, and those keys match the named arguments for the constructor, it will create a new instance of the object with the same values. In order to modify the values that we want changed, we do so against the array before passing it to the constructor. And this approach will work regardless of inheritance level: as long as all the properties are public, and all set through the constructor.
So unlike Cloneable
, we don’t need to worry about the level of inheritance, because the assignment of values to properties is all handled by the object constructor.
We can take the same approach even if we’re favouring Composition; although (as with Cloneable
) we have to do that manually within our own makePayment()
code.
class Balance {
public function __construct(
public readonly Money $money,
public readonly DateTimeInterface $lastTransactionDateTime = new DateTimeImmutable(timezone: new DateTimezone('UTC'))
) {}
public function makePayment(Payment $payment): Balance {
if ($payment->currency->code !== $this->money->currency->code) {
throw new Exception(sprintf('Payment must be in %s', $this->money->currency->name));
}
$objectProperties = (array) $this;
$objectProperties['money'] = new Money(value: $this->money->value + $payment->value, currency: $this->money->currency);
$objectProperties['lastTransactionDateTime'] = new DateTimeImmutable(timezone: new DateTimezone('UTC'));
$newBalance = new Balance(...$objectProperties);
return $newBalance;
}
}
We just need to ensure that nested objects that we want to modify are also instantiated with their new values when we set the new values in the array, and assign those newly instantiated nested object as the array value.
So while on the surface readonly
properties seem really useful, there are some problems with using them for Value objects where we would normally use clone to create a new version of the object. These problems aren’t insurmountable, there are solutions; but neither Cloneable
, nor casting to an array and creating a new instance from that array are perfect.
In either case, I do believe that the benefits of readonly
properties outweigh the drawbacks; but we need to be aware of their limitations, and understand how to work with them. Hopefully this article has helped provide some of that understanding.
Pingback: PHP 8.2 – The Release of Deprecations | Mark Baker's Blog
Pingback: ReadOnly Gotchas – A few more limitations with PHP 8.1 ReadOnly Properties | Mark Baker's Blog