tempest Want to connect with me and many other PHP enthusiasts? Join the Tempest Discord!

Readonly or private(set)

PHP is a mess.

Let's just put that up front. I love it, but some parts are just… so frustrating. One good example is having to choose between readonly properties or properties that are privately writeable. I'll show you why.

Readonly properties were added in PHP 8.1. They allow a property to be set only once, and never change afterwards:

final class Book
{
    public function __construct(
        public readonly string $title,
    ) {}
}
$book = new Book('Timeline Taxi');

$book->title = 'Timeline Taxi 2';
Cannot modify readonly property Book::$title

For the record, it's totally ok to construct an object without a value for a readonly property. PHP will only check a property's validity when reading it; that was part of the design of typed properties back in PHP 7.4:

final class Book
{
    public readonly string $title;
}

$book = new Book();
echo $book->title;
Typed property Book::$title must not be accessed before initialization

// Setting a value after an object has been constructed is totally fine:
$book->title = 'Timeline Taxi';

Then PHP 8.4 came along with "asymmetric visibility" which makes it possible to define a different property visibility (public, protected, or private), depending on what you're doing with that property: reading from it or writing to it — get or set operations.

You could, for example have a private(set) property:

final class Book
{
    public function __construct(
        private(set) string $title,
    ) {}
}

private(set) essentially means "this property is publicly readable but only privately writeable." It's actually a shorthand for public private(set). In other words, you can change the title of a book from within the class itself:

final class Book
{
    public function __construct(
        private(set) string $title,
    ) {}
    
    public function markDraft(): self
    {
        // Perfectly fine to change the title from within the class itself
        $this->title .= ' (Draft)';
    
        return $this;
    }
}

But not from outside:

$book = new Book('Timeline Taxi');

$book->title .= ' (Draft)';
Cannot modify readonly property Book::$title

So why does all of this matter? These are two separate features — right? One is about preventing changes to properties once they've gotten a value, the other one is about restricting when that value can be changed.

When readonly came along (three years before asymmetric visibility), many people used it to create so-called data objects; objects that represent data in a structured and typed way, which are then passed around all throughout you code. It's a very powerful pattern, and I wrote about it back in 2018 — in case you want to get some more background information.

The addition of readonly made it so that we could build classes with public properties, without having to add any getters or setters; these properties could never change after they had been created anyway, so why not skip all the boilerplate?

final class Book
{
    public function __construct(
        public readonly string $title,
        public readonly Author $author,
        public readonly ChapterCollection $chapters,
        public readonly Publisher $publisher,
        public readonly null|DateTimeImmutable $publishedAt = null,
    ) {}
}

The practice got so popular that PHP 8.2 added a shorthand for "classes that only have readonly properties":

final readonly class Book
{
    public function __construct(
        public string $title,
        public Author $author,
        public ChapterCollection $chapters,
        public Publisher $publisher,
        public null|DateTimeImmutable $publishedAt = null,
    ) {}
}

But then came along PHP 8.4, with asymmetric visibility. And while it seems like it's a completely different feature, you could achieve the same result of "an object that can't be tampered with from the outside" by marking properties as private(set):

final class Book
{
    public function __construct(
        private(set) string $title,
        private(set) Author $author,
        private(set) ChapterCollection $chapters,
        private(set) Publisher $publisher,
        private(set) null|DateTimeImmutable $publishedAt = null,
    ) {}
}

You could make the case that properties with asymmetric visibility are better than readonly properties, because they still allow changes from within the class itself — it's a bit more flexible.

On top of that, sometimes you do want to make changes to an object with readonly properties, but only by copying that data into a new object. Unfortunately, we don't have a proper clone with expression in PHP to overwrite readonly properties while cloning, and the upcoming changes in PHP 8.5 to clone actually don't address readonly properties correctly. It's a pretty deep rabbit hole, I made a video about it if you want to learn more:

But there's no denying: readonly when used for data objects (so most of the time) is far less ideal compared to using asymmetric visibility. The problem is: readonly has been in PHP for three years prior to asymmetric visibility, and is used in so many places. I noticed how newer code — even within my own codebase — uses asymmetric visibility, while older places use readonly properties. This creates a lot of confusion, especially if you're maintaining open source code for people to use. There are semantic differences between readonly and private(set), especially if you're talking about cloning objects; they just happen to kind of both work for a very common use case.

So should I replace readonly with private(set) everywhere it makes sense? Should readonly maybe be deprecated in the future? Should I embrace the fact that readonly came first and stick with it even though there's a better alternative? How do newcomers to PHP know which one to choose?

And the thing is… we saw this coming. The first time asymmetric visibility was pitched, I wrote about how these two features clash and will confuse PHP developers for years to come. And while I like asymmetric visibility more, the addition of it after readonly properties is causing a lot of confusion — at least for me, maybe I'm the only one? You should let me know!

And you know the saddest part? All these features: readonly properties, readonly classes, asymmetric visibility, constructor property promotion — which I know didn't mention that's for another time; all these features are basically workarounds for something far more simple: having proper structs in PHP to represent data in a typed way.

struct Book
{
    string $title;
    Author $author;
    ChapterCollection $chapters;
    Publisher $publisher;
    null|DateTimeImmutable $publishedAt = null;
}

And yes, I know, the features I mentioned do a lot more than intermediate for structs, but I'm confident in saying that I would exchange all of them if we got proper structs in PHP.

So yeah, PHP is mess. It's a beautiful lovely mess, and I wouldn't want to change it for another language.

But what a mess.