Writing data transfer objects and value objects in PHP have become significantly easier over the years. Take for example a look at a DTO in PHP 5.6:
class BlogData
{
/** @var string */
private $title;
/** @var Status */
private $status;
/** @var \DateTimeImmutable|null */
private $publishedAt;
/**
* @param string $title
* @param Status $status
* @param \DateTimeImmutable|null $publishedAt
*/
public function __construct(
$title,
$status,
$publishedAt = null
) {
$this->title = $title;
$this->status = $status;
$this->publishedAt = $publishedAt;
}
/**
* @return string
*/
public function getTitle()
{
return $this->title;
}
/**
* @return Status
*/
public function getStatus()
{
return $this->status;
}
/**
* @return \DateTimeImmutable|null
*/
public function getPublishedAt()
{
return $this->publishedAt;
}
}
And compare it to its PHP 8.0’s equivalent:
class BlogData
{
public function __construct(
private string $title,
private Status $status,
private ?DateTimeImmutable $publishedAt = null,
) {}
public function getTitle(): string
{
return $this->title;
}
public function getStatus(): Status
{
return $this->status;
}
public function getPublishedAt(): ?DateTimeImmutable
{
return $this->publishedAt;
}
}
That’s already quite the difference, though I think there’s still one big issue: all those getters. Personally, I don’t use them anymore since PHP 8.0 with its promoted properties. I simply prefer to use public properties instead of adding getters:
class BlogData
{
public function __construct(
public string $title,
public Status $status,
public ?DateTimeImmutable $publishedAt = null,
) {}
}
Object-oriented purists don’t like this approach though: an object’s internal status shouldn’t be exposed directly, and definitely not be changeable from the outside.
In our projects at Spatie, we have an internal style guide rule that DTOs and VOs with public properties shouldn’t be changed from the outside; a practice that seems to work fairly well, we’ve been doing it for quite some time now without running into any problems.
However, yes; I agree that it would be better if the language ensured that public properties couldn’t be overwritten at all.
class BlogData
{
public function __construct(
public readonly string $title,
public readonly Status $status,
public readonly ?DateTimeImmutable $publishedAt = null,
) {}
}
This keyword basically does what its name suggests: once a property is set, it cannot be overwritten anymore:
$blog = new BlogData(
title: 'PHP 8.1: readonly properties',
status: Status::PUBLISHED,
publishedAt: now()
);
$blog->title = 'Another title';
Error: Cannot modify readonly property BlogData::$title
Knowing that, when an object is constructed, it won’t change anymore, gives a level of certainty and peace when writing code: a whole range of unforeseen data changes simply can’t happen anymore.
Of course, you still want to be able to copy data over to a new object, and maybe change some properties along the way. We’ll discuss how to do that with read-only properties later in this post. First, let’s look at them in depth.
Only typed properties
Readonly properties can only be used in combination with typed properties:
class BlogData
{
public readonly string $title;
public readonly $mixed;
}
You can however use mixed
as a type hint:
class BlogData
{
public readonly string $title;
public readonly mixed $mixed;
}
The reason for this restriction is that by omitting a property type, PHP will automatically set a property’s value to null
if no explicit value was supplied in the constructor. This behavior, combined with read-only, would cause unnecessary confusion.
Both normal and promoted properties
You’ve already seen examples of both: readonly
can be added both on normal, as well as promoted properties:
class BlogData
{
public readonly string $title;
public function __construct(
public readonly Status $status,
) {}
}
No default value
Readonly properties can not have a default value:
class BlogData
{
public readonly string $title = 'Readonly properties';
}
That is unless they are promoted properties:
class BlogData
{
public function __construct(
public readonly string $title = 'Readonly properties',
) {}
}
The reason that it is allowed for promoted properties, is because the default value of a promoted property isn’t used as the default value for the class property, but only for the constructor argument. Under the hood, the above code would transpile to this:
class BlogData
{
public readonly string $title;
public function __construct(
string $title = 'Readonly properties',
) {
$this->title = $title;
}
}
You can see how the actual property doesn’t get assigned a default value. The reason for not allowing default values on read-only properties, by the way, is that they wouldn’t be any different from constants in that form.
Inheritance
You’re not allowed to change the read-only flag during inheritance:
class Foo
{
public readonly int $prop;
}
class Bar extends Foo
{
public int $prop;
}
This rule goes in both directions: you’re not allowed to add or remove the readonly
flag during inheritance.
Unset is not allowed
Once a read-only property is set, you cannot change it, not even unset it:
$foo = new Foo('value');
unset($foo->prop);
Reflection
There’s a new ReflectionProperty::isReadOnly()
method, as well as a ReflectionProperty::IS_READONLY
flag.
Cloning
So, if you can’t change read-only properties, and if you can’t unset them, how can you create a copy of your DTOs or VOs and change some of their data? You can’t clone them, because you wouldn’t be able to overwrite their values. There’s actually an idea to add a clone with construct in the future that allows this behavior, but that doesn’t solve our problem now.
Well, you can copy over objects with changed read-only properties, if you rely on a little bit of reflection magic. By creating an object without calling its constructor (which is possible using reflection), and then by manually copying each property over — sometimes overwriting its value — you can in fact “clone” an object and change its read-only properties.