rfc:clone_with_v2

PHP RFC: Clone with v2

Introduction

Both readonly properties and the cloning of objects are commonplace in a lot of PHP applications, but these two concepts don’t fully work together in all cases.

This RFC proposes to address this issue by allowing the clone language construct to work well with readonly properties.

One common usage pattern for object cloning is “immutable value-objects” having ->withProperty() methods returning a new object with some property changed. This doesn’t work smoothly with readonly properties at the moment.

<?php
 
final readonly class Response {
    public function __construct(
        public int $statusCode,
        public string $reasonPhrase,
        // ...
    ) {}
 
    public function withStatus($code, $reasonPhrase = ''): Response
    {
        // Only works if all properties are assignable via __construct
        $values = get_object_vars($this);
        $values['statusCode'] = $code;
        $values['reasonPhrase'] = $reasonPhrase;
        return new self(
            ...$values
        );
    }
}

To address this, we propose what we consider a simple change that makes clone look and mostly behave like a regular function call. This will align clone with current PHP users' expectations regarding syntax and semantics, while still respecting property visibility rules.

    public function withStatus($code, $reasonPhrase = ''): Response
    {
        return clone(
            $this,
            statusCode: $code,
            reasonPhrase: $reasonPhrase,
        );
    }

The main reason we think a clone() function that directly updates the properties is preferable to one that passes the updated properties to the magic __clone() method of an object is that it allows objects with public properties to be cloned with changed properties from the outside without assistance by the object. Furthermore internally changes can be localized in appropriate methods and not centralized in the __clone() method.

Prior Works

This change was proposed in https://wiki.php.net/rfc/clone_with and discussed https://externals.io/message/120048

Máté, the original RFC author, dropped the RFC and had no objections to us proposing this continuation trying to address the same need.

Proposal

We propose to change clone from a standalone keyword to a language construct that optionally accepts parenthesis and a parameter list, compiling to a call to a new clone() function with the following signature:

function clone(object $object, mixed ...$updatedProperties): object {}

allowing for all the following syntax examples to be valid:

$y = clone $x;
$y = clone($x);
$y = clone($x, foo: $foo, bar: $bar);
$y = clone($x, ...$array);
$y = clone($x, ...[
    "foo" => $foo,
    "bar" => $bar,
]);

By promoting clone to a function, it will also be possible to use it as a callable. E.g. in array_map.

Technical Details

  • A magic __clone() method will be called before the new properties are assigned.
  • Assignment of new properties happens in parameter order. As a result, should there an error during the assignment (e.g. because of property hooks), the error will be raised for the first impacted property.
  • Property assignments are made just as a regular assignment would be. Meaning all regular PHP rules apply with only the readonly state being “unlocked”. The currently linked implementation “locks” a property if it modified within __clone(), if this is useful is up for debate.
    1. Visibility rules for property access are enforced. Clone can't be used to modify the internal state of objects from outside.
    2. Property hooks work as expected. Setters are called during clone calls.
    3. Dynamic properties respect #[AllowDynamicProperties].
    4. __set works as expected and is called during clone as it would be during normal assignments.
    5. The behavior of Lazy Objects is not affected, as before they will be de-lazyfied and cloned.

Design Goals

When re-proposing this RFC, one of our goals was to take all previous discussion into account and propose something that is small in scope and cognitive load.

This RFC explicitly rejects any BC impacting syntax choices like the proposed with keyword suggested previously, as we don't feel the scope of the feature warrants any BC impact.

Likewise, all syntax has to exist in other parts of PHP already, to not add cognitive load to readers when they come across it.

Examples

Using clone as a callable

<?php
 
$x = [new stdClass, new stdClass];
 
var_dump(array_map(clone(...), $x));
array_map('clone', $x); // Works as well
 
// array(2) {
//  [0] => object(stdClass)#4 (0) {}
//  [1] => object(stdClass)#5 (0) {}
// }

Cloning with new properties

<?php
 
class Foo {
 
    public function __construct(
        private readonly int $c = 1,
    ) {}
 
    public function just_clone() {
        return clone $this;
    }
 
    public function clone_with_named_argument($newC) {
        return clone($this, c: $newC);
    }
 
    public function clone_with_array_destructuring($newC) {
        return clone($this, ...["c" => $newC]);
    }
}
 
$x = new Foo();
 
// object(Foo)#1 (1) { ["c":"Foo":private]=> int(1) }
var_dump($x);
 
// object(Foo)#2 (1) { ["c":"Foo":private]=> int(1) }
var_dump($x->just_clone());
 
// object(Foo)#2 (1) { ["c":"Foo":private]=> int(5) }
var_dump($x->clone_with_named_argument(5));
 
// object(Foo)#2 (1) { ["c":"Foo":private]=> int(10) }
var_dump($x->clone_with_array_destructuring(10));

Visibility rules

Public properties can be changed from the outside during cloning

<?php
 
class Foo {
    public int $pub = 1;
}
 
$foo = new Foo();
var_dump(clone($foo, pub: 5));
 
// object(Foo)#2 (1) {
//   ["pub"]=>
//   int(5)
// }

Public readonly properties can be changed during cloning from within the class.

<?php
 
class Foo {
    public readonly int $pub;
 
    public function withPub($newPub) {
        return clone($this, pub: $newPub);
    }
}
 
$foo = new Foo();
var_dump($foo->withPub(5));
 
// object(Foo)#2 (1) {
//   ["pub"]=>
//   int(5)
// }

Public readonly properties need a public(set) to be able to be cloned from outside the class, as the property is protected(set) by default. This is existing PHP behavior and wasn't changed in this RFC. Changing the visibility of readonly properties is outside the scope of this RFC, and this example is listed for completeness’s sake and to thoroughly explain the existing edge cases.

<?php
 
class Foo {
    public readonly int $pub;
}
 
$foo = new Foo();
var_dump(clone($foo, pub: 5));
// Fatal error: Uncaught Error: Cannot modify protected(set) readonly property Foo::$pub from global scope in ...

Protected and private properties can be changed from within their respective scopes.

<?php
 
class Foo {
    protected readonly int $prot;
    private readonly int $priv;
 
    public function withPriv(int $priv) {
        return clone($this, priv: $priv);
    }
}
 
class Bar extends Foo {
    public function withProt(int $prot) {
        return clone($this, prot: $prot);
    }
}
 
$foo = new Foo();
 
// object(Foo)#2 (1) { ["prot":protected]=> uninitialized(int) ["priv":"Foo":private]=> int(10) }
var_dump($foo->withPriv(10));
 
$bar = new Bar();
// object(Bar)#3 (1) { ["prot":protected]=> int(5) ["priv":"Foo":private]=> uninitialized(int) }
var_dump($bar->withProt(5));
 
// Fatal error: Uncaught Error: Cannot access protected property Bar::$prot in
clone($bar, prot: 5);

Assignment order and property hooks

Property hooks are executed just as they would be with normal setters.

<?php
 
class Foo {
    public string $hooked = 'default' {
        set (string $value) {
            $this->hooked = strtoupper($value);
        }
    }
}
 
$x = new Foo();
 
// object(Foo)#1 (1) { ["hooked"] => string(7) "default" }
var_dump($x);
 
// object(Foo)#2 (1) { ["hooked"] => string(7) "UPDATED" }
var_dump(clone($x, hooked: 'updated'));

Throw order

Properties are set in the order they are passed in. The first error/exception raised cancels the rest of the clone operation.

<?php
 
class Foo {
 
    public function __construct(
        private int $a,
        private int $b,
        private int $c,
    ) {}
 
    public function withInvalidValues() {
        return clone($this, a: 5, b: 'invalid argument', c: 'also invalid');
    }
}
 
$foo = new Foo(1,2,3);
$foo->withInvalidValues();
 
// Fatal error: Uncaught TypeError: Cannot assign string to property Foo::$b of type int in ...

Combined with property hooks:

<?php
 
class Foo {
 
    public function __construct(
        private int $a {
            set (int $value) {
                echo "Got $value", PHP_EOL;
            }
        },
        private int $b {
            set (int $value) {	
                if ($value > 3) {
                    throw new InvalidArgumentException("Rejecting $value");
                }
            }
        },
        private int $c {
            set (int $value) {
                echo "Got $value", PHP_EOL;
            }
        },
    ) {}
 
    public function withInvalidValues() {
        return clone($this, a: 5, b: 6, c: 7);
    }
}
 
$foo = new Foo(1,2,3);
$foo->withInvalidValues();
 
// Got 1
// Got 2
// Got 3
// Got 5
 
// Fatal error: Uncaught InvalidArgumentException: Rejecting 6 in ...

Readonly

<?php
 
class Foo {
    public function __construct(
        public(set) readonly int $c,
    ) {}
}
 
$x = new Foo(1);
 
var_dump($x);
var_dump(clone($x, c: 2));
var_dump(clone($x, ...["c" => 2]));
 
// Without the (set) modifier on public, we'd get:
// Fatal error: Uncaught Error: Cannot modify protected(set) readonly property Foo::$c from global scope in example.php:12
// This is how readonly properties work in PHP currently and outside the scope of this RFC

Backward Incompatible Changes

No BC break is expected. The new syntax is optional and the old syntax will continue to work as before.

By not touching how __clone() works, we also avoid any BC impact for users of this function.

Proposed PHP Version(s)

Next PHP 8.x (8.5)

RFC Impact

To SAPIs

None

To Existing Extensions

The current implementation adds a new clone_obj_with object handler that needs to be implemented to make internal classes compatible with clone with when custom cloning behavior is already implemented with clone_obj. If the default clone_obj handler is used, clone with will transparently work.

The existing behavior of clone_obj when no properties are given remains fully compatible. Thus, the only impact is that extensions that are not updated will be incompatible with clone with, but cloning behavior without properties remains compatible for existing PHP programs.

To Opcache

None

New Constants

None

php.ini Defaults

None

Open Issues

With the current implementation, the name of the first parameter couldn't be used as a position argument to the second parameter.

function clone(object $object, mixed ...$updatedProperties): object {}

Given this signature $object wouldn't be allowed to be passed like this:

<?php
 
class Foo {
 
    public function __construct(
        private readonly object $object,
    ) {}
 
    public function clone_with_new_object() {
        return clone($this, object: new stdClass);
    }
}
 
$x = new Foo(new stdClass);
$x->clone_with_new_object();
 
// Fatal error: Uncaught Error: Named parameter $object overwrites previous argument in

An easy and maybe good enough fix would be to call the first parameter __object to avoid conflicts in real world applications.

Alternatively, we could drop the variadic syntax and instead only accept an array as the second parameter. We're looking for feedback here in the discussion.

Unaffected PHP Functionality

Future Scope

None

Proposed Voting Choices

2/3 majority:

Vote

Patches and Tests

Implementation

TBD

References

Rejected Features

For the reasons mentioned in the introduction and the Backwards Incompatible Changes section, changes to the magic __clone() method are out of scope for this RFC.

rfc/clone_with_v2.txt · Last modified: 2025/05/09 21:32 by theodorejb


OSZAR »