Timeline Taxi Out now: my sci-fi novel Timeline Taxi is published!

PHP 8.1: readonly properties

Important note: PHP 8.2 adds a way of making whole classes readonly at once: readonly classes.

Writing data transfer objects and value objects in PHP has 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. Well, PHP 8.1 solves all these issues by introducing the readonly keyword:

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 readonly 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 behaviour, combined with readonly, 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 readonly 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 readonly 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 readonly 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 readonly properties, and if you can't unset them, how can you create a copy of your DTOs or VOs and change some of its data? You can't clone them, because you wouldn't be able to overwrite its values. There's actually an idea to add a clone with construct in the future that allows this behaviour, but that doesn't solve our problem now.

Well, you can copy over objects with changed readonly 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 readonly properties.

I made a small package to do exactly that, here's what it looks like:

class BlogData
{
    use Cloneable;

    public function __construct(
        public readonly string $title,
    ) {}
}

$dataA = new BlogData('Title');

$dataB = $dataA->with(title: 'Another title');

I actually wrote a dedicated blogpost explaining the mechanics behind all of this, you can read it here.

# Readonly classes

Finally, I should also mention the addition of readonly classes in PHP 8.2. In cases where all properties of your class are readonly (which often happens with DTOs or VOs), you can mark the class itself as readonly. This means you won't have to declare every individual property as readonly — a nice shorthand!

readonly class BlogData
{
    public function __construct(
        public string $title,
        public Status $status,
        public ?DateTimeImmutable $publishedAt = null,
    ) {}
}

Noticed a tpyo? You can submit a PR to fix it. If you want to stay up to date about what's happening on this blog, you can subscribe to my mailing list: send an email to brendt@stitcher.io, and I'll add you to the list.

So, that's all there is to say about readonly properties. I think they are a great feature if you're working on projects that deal with lots of DTOs and VOs, and require you to carefully manage the data flow throughout your code. Immutable objects with readonly properties are a significant help in doing so.

I'm looking forward to using them, what about you? Let me know on Twitter or via e-mail!