Cloning readonly properties in PHP 8.3

PHP 8.3 adds the possibility of overwriting readonly property values while cloning an object. Don't be mistaken though: you're not able to clone any object and overwrite their readonly values from any place. This feature only addresses a very specific (but important) edge case.

Let's take a look!

In an ideal world, we'd be able to clone classes with readonly properties, based on a user-defined set of values. The so called clone with syntax (which doesn't exist):

readonly class Post
{
    public function __construct(
        public string $title,
        public string $author,
        public DateTime $createdAt,
    ) {}
}

$post = new Post(
    title: 'Hello World',
    // …
);

 // This isn't possible!
$updatedPost = clone $post with {
    title: 'Another One!',
};

Reading the title of the current RFC: "Readonly properties can be reinitialized during cloning" — you might think something like clone with this is now possible. However… it isn't. The RFC allows only allows for one specific operation: to overwrite readonly values in the magic __clone method:

readonly class Post
{
    public function __construct(
        public string $title,
        public string $author,
        public DateTime $createdAt,
    ) {}
    
    public function __clone()
    {
        $this->createdAt = new DateTime(); 
        // This is allowed,
        // even though `createdAt` is a readonly property.
    }
}

Is this useful? It is! Say you want to clone objects with nested objects — a.k.a. making "deep clones"; then this RFC allows you to clone those nested objects as well, and overwrite them in your newly created clone — even when they are readonly properties.

readonly class Post
{
    public function __clone()
    {
        $this->createdAt = clone $this->createdAt; 
        // Creates a new DateTime object,
        // instead of reusing the reference
    }
}

Without this RFC, you'd be able to clone $post, but it would still hold a reference to the original $createdAt object. Say you'd make changes to that object (which is possible since readonly only prevents the property assigned from changing, not from its inner values being changed):

$post = new Post(/* … */);

$otherPost = clone $post;

$post->createdAt->add(new DateInterval('P1D'));

$otherPost->createdAt === $post->createdAt; // true :(

Then you'd end up with the $createdAt date changed on both objects!

Thanks to this RFC, we can make real clones, with all their nested properties cloned as well, even when these properties are readonly:

$post = new Post(/* … */);

$otherPost = clone $post;

$post->createdAt->add(new DateInterval('P1D'));

$otherPost->createdAt === $post->createdAt; // false :)

# On a personal note

I think it's good that PHP 8.3 makes it possible deep cloning readonly properties. However, I have mixed feelings about this implementation. Imagine for a second that clone with existed in PHP, then all of the above would have been unnecessary. Take a look:

// Again, this isn't possible!
$updatedPost = clone $post with { 
    createdAt: clone $post->createdAt,
};

Now imagine that clone with gets added in PHP 8.4 — pure speculation, of course. It means we'd have two ways of doing the same thing in PHP. I don't know about you, but I don't like it when languages or frameworks offer several ways of doing the same thing. As far as I'm concerned, that's suboptimal language design at best.

This is, of course, assuming that clone with would be able to automatically map values to properties without the need of manually implementing mapping logic in __clone. I'm also assuming that clone with can deal with property visibility: only able to change public properties from the outside, but able to change protected and private properties when used inside a class.

Awhile ago, I wrote about how PHP internals seem to be divided, one group comes up with one solution, while another group wants to take another approach. To me, it's a clear drawback of designing by committee.

Full disclosure — the RFC mentions clone with as a future scope:

None of the envisioned ideas for the future collide with the proposals in this RFC. They could thus be considered separately later on.

But I tend to disagree with this statement, at least assuming that clone with would work without having to implement any userland code. If we'd follow the trend of this current RFC, I could imagine someone suggesting to add clone with only as a way to pass data into __clone, and have users deal with it themselves:

readonly class Post
{
    public function __clone(...$properties)
    {
        foreach ($properties as $name => $value) {
            $this->$name = $value;
        }
    }
}

However, I really hope this isn't the way clone with gets implemented; because you'd have to add a __clone implementation on every readonly class.

So, assuming the best case where clone with gets added, and where it is able to automatically map values; then the functionality of this current RFC gets voided, and makes it so that we have two ways of doing the same thing. It will confuse users because it faces them with yet another a decision when coding. I think PHP has grown confusing enough as is, and I'd like to see that change.

On the other hand, I do want to mention that I don't oppose this RFC on its own. I think Nicolas and Máté did a great job coming up with a solid solution to a real-life problem.


PS: in case someone wants to make the argument for the current RFC because you only need to implement __clone once per object and not worry about it on call-site anymore. There's one very important details missing from these examples in isolation: deep copying doesn't happen with a simple clone call. Most of the time, packages like deep-copy are used, and thus, the potential overhead that comes with my clone with example is already taken care off by those packages and don't bother end users.