PHP RFC: Clone with v2
- Version: 1.0
- Date: 2025-03-30
- Author: Volker Dusch ([email protected]), Tim Düsterhus ([email protected])
- Status: Draft
- First Published at: https://wiki.php.net/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.- Visibility rules for property access are enforced. Clone can't be used to modify the internal state of objects from outside.
- Property hooks work as expected. Setters are called during clone calls.
- Dynamic properties respect
#[AllowDynamicProperties]
. __set
works as expected and is called during clone as it would be during normal assignments.- 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
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.