Skip to content

RFC: Clone with v2 #18747

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 9 commits into from
Jul 17, 2025
Merged
3 changes: 3 additions & 0 deletions NEWS
Original file line number Diff line number Diff line change
@@ -2,6 +2,9 @@ PHP NEWS
|||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
?? ??? ????, PHP 8.5.0alpha3

- Core:
. Add clone-with support to the clone() function. (timwolla, edorian)

- Curl:
. Add support for CURLINFO_CONN_ID in curl_getinfo() (thecaliskan)
. Add support for CURLINFO_QUEUE_TIME_T in curl_getinfo() (thecaliskan)
3 changes: 2 additions & 1 deletion UPGRADING
Original file line number Diff line number Diff line change
@@ -408,7 +408,8 @@ PHP 8.5 UPGRADE NOTES
. get_exception_handler() allows retrieving the current user-defined exception
handler function.
RFC: https://wiki.php.net/rfc/get-error-exception-handler
. The clone language construct is now a function.
. The clone language construct is now a function and supports reassigning
(readonly) properties during cloning via the new $withProperties parameter.
RFC: https://wiki.php.net/rfc/clone_with_v2

- Curl:
71 changes: 71 additions & 0 deletions Zend/tests/clone/clone_with_001.phpt
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
--TEST--
Clone with basic
--FILE--
<?php

class Dummy { }

$x = new stdClass();

$foo = 'FOO';
$bar = new Dummy();
$array = [
'baz' => 'BAZ',
'array' => [1, 2, 3],
];

var_dump(clone $x);
var_dump(clone($x));
var_dump(clone($x, [ 'foo' => $foo, 'bar' => $bar ]));
var_dump(clone($x, $array));
var_dump(clone($x, [ 'obj' => $x ]));

var_dump(clone($x, [
'abc',
'def',
new Dummy(),
'named' => 'value',
]));

?>
--EXPECTF--
object(stdClass)#%d (0) {
}
object(stdClass)#%d (0) {
}
object(stdClass)#%d (2) {
["foo"]=>
string(3) "FOO"
["bar"]=>
object(Dummy)#%d (0) {
}
}
object(stdClass)#%d (2) {
["baz"]=>
string(3) "BAZ"
["array"]=>
array(3) {
[0]=>
int(1)
[1]=>
int(2)
[2]=>
int(3)
}
}
object(stdClass)#%d (1) {
["obj"]=>
object(stdClass)#%d (0) {
}
}
object(stdClass)#%d (4) {
["0"]=>
string(3) "abc"
["1"]=>
string(3) "def"
["2"]=>
object(Dummy)#%d (0) {
}
["named"]=>
string(5) "value"
}
114 changes: 114 additions & 0 deletions Zend/tests/clone/clone_with_002.phpt
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
--TEST--
Clone with respects visiblity
--FILE--
<?php

class P {
public $a = 'default';
protected $b = 'default';
private $c = 'default';
public private(set) string $d = 'default';

public function m1() {
return clone($this, [ 'a' => 'updated A', 'b' => 'updated B', 'c' => 'updated C', 'd' => 'updated D' ]);
}
}

class C extends P {
public function m2() {
return clone($this, [ 'a' => 'updated A', 'b' => 'updated B', 'c' => 'dynamic C' ]);
}

public function m3() {
return clone($this, [ 'd' => 'inaccessible' ]);
}
}

class Unrelated {
public function m3(P $p) {
return clone($p, [ 'b' => 'inaccessible' ]);
}
}

$p = new P();

var_dump(clone($p, [ 'a' => 'updated A' ]));
var_dump($p->m1());

$c = new C();
var_dump($c->m1());
var_dump($c->m2());
try {
var_dump($c->m3());
} catch (Error $e) {
echo $e::class, ": ", $e->getMessage(), PHP_EOL;
}

try {
var_dump(clone($p, [ 'b' => 'inaccessible' ]));
} catch (Error $e) {
echo $e::class, ": ", $e->getMessage(), PHP_EOL;
}

try {
var_dump(clone($p, [ 'd' => 'inaccessible' ]));
} catch (Error $e) {
echo $e::class, ": ", $e->getMessage(), PHP_EOL;
}

try {
var_dump((new Unrelated())->m3($p));
} catch (Error $e) {
echo $e::class, ": ", $e->getMessage(), PHP_EOL;
}

?>
--EXPECTF--
object(P)#%d (4) {
["a"]=>
string(9) "updated A"
["b":protected]=>
string(7) "default"
["c":"P":private]=>
string(7) "default"
["d"]=>
string(7) "default"
}
object(P)#%d (4) {
["a"]=>
string(9) "updated A"
["b":protected]=>
string(9) "updated B"
["c":"P":private]=>
string(9) "updated C"
["d"]=>
string(9) "updated D"
}
object(C)#%d (4) {
["a"]=>
string(9) "updated A"
["b":protected]=>
string(9) "updated B"
["c":"P":private]=>
string(9) "updated C"
["d"]=>
string(9) "updated D"
}

Deprecated: Creation of dynamic property C::$c is deprecated in %s on line %d
object(C)#%d (5) {
["a"]=>
string(9) "updated A"
["b":protected]=>
string(9) "updated B"
["c":"P":private]=>
string(7) "default"
["d"]=>
string(7) "default"
["c"]=>
string(9) "dynamic C"
}
Error: Cannot modify private(set) property P::$d from scope C
Error: Cannot access protected property P::$b
Error: Cannot modify private(set) property P::$d from global scope
Error: Cannot access protected property P::$b
23 changes: 23 additions & 0 deletions Zend/tests/clone/clone_with_003.phpt
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
--TEST--
Clone with supports property hooks
--FILE--
<?php

class Clazz {
public string $hooked = 'default' {
set {
$this->hooked = strtoupper($value);
}
}
}

$c = new Clazz();

var_dump(clone($c, [ 'hooked' => 'updated' ]));

?>
--EXPECTF--
object(Clazz)#%d (1) {
["hooked"]=>
string(7) "UPDATED"
}
82 changes: 82 additions & 0 deletions Zend/tests/clone/clone_with_004.phpt
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
--TEST--
Clone with evaluation order
--FILE--
<?php

class Clazz {
public string $hooked = 'default' {
set {
echo __FUNCTION__, PHP_EOL;

$this->hooked = strtoupper($value);
}
}

public string $maxLength {
set {
echo __FUNCTION__, PHP_EOL;

if (strlen($value) > 5) {
throw new \Exception('Length exceeded');
}

$this->maxLength = $value;
}
}

public string $minLength {
set {
echo __FUNCTION__, PHP_EOL;

if (strlen($value) < 5) {
throw new \Exception('Length unsufficient');
}

$this->minLength = $value;
}
}
}

$c = new Clazz();

var_dump(clone($c, [ 'hooked' => 'updated' ]));
echo PHP_EOL;
var_dump(clone($c, [ 'hooked' => 'updated', 'maxLength' => 'abc', 'minLength' => 'abcdef' ]));
echo PHP_EOL;
var_dump(clone($c, [ 'minLength' => 'abcdef', 'hooked' => 'updated', 'maxLength' => 'abc' ]));

?>
--EXPECTF--
$hooked::set
object(Clazz)#%d (1) {
["hooked"]=>
string(7) "UPDATED"
["maxLength"]=>
uninitialized(string)
["minLength"]=>
uninitialized(string)
}

$hooked::set
$maxLength::set
$minLength::set
object(Clazz)#%d (3) {
["hooked"]=>
string(7) "UPDATED"
["maxLength"]=>
string(3) "abc"
["minLength"]=>
string(6) "abcdef"
}

$minLength::set
$hooked::set
$maxLength::set
object(Clazz)#%d (3) {
["hooked"]=>
string(7) "UPDATED"
["maxLength"]=>
string(3) "abc"
["minLength"]=>
string(6) "abcdef"
}
64 changes: 64 additions & 0 deletions Zend/tests/clone/clone_with_005.phpt
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
--TEST--
Clone with error handling
--FILE--
<?php

class Clazz {
public string $hooked = 'default' {
set {
echo __FUNCTION__, PHP_EOL;

$this->hooked = strtoupper($value);
}
}

public string $maxLength {
set {
echo __FUNCTION__, PHP_EOL;

if (strlen($value) > 5) {
throw new \Exception('Length exceeded');
}

$this->maxLength = $value;
}
}

public string $minLength {
set {
echo __FUNCTION__, PHP_EOL;

if (strlen($value) < 5) {
throw new \Exception('Length insufficient');
}

$this->minLength = $value;
}
}
}

$c = new Clazz();

try {
var_dump(clone($c, [ 'hooked' => 'updated', 'maxLength' => 'abcdef', 'minLength' => 'abc' ]));
} catch (Throwable $e) {
echo $e::class, ": ", $e->getMessage(), PHP_EOL;
}

echo PHP_EOL;

try {
var_dump(clone($c, [ 'hooked' => 'updated', 'minLength' => 'abc', 'maxLength' => 'abcdef' ]));
} catch (Throwable $e) {
echo $e::class, ": ", $e->getMessage(), PHP_EOL;
}

?>
--EXPECT--
$hooked::set
$maxLength::set
Exception: Length exceeded

$hooked::set
$minLength::set
Exception: Length insufficient
16 changes: 16 additions & 0 deletions Zend/tests/clone/clone_with_006.phpt
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
--TEST--
Clone with error cases
--FILE--
<?php

$x = new stdClass();

try {
var_dump(clone($x, 1));
} catch (Throwable $e) {
echo $e::class, ": ", $e->getMessage(), PHP_EOL;
}

?>
--EXPECT--
TypeError: clone(): Argument #2 ($withProperties) must be of type array, int given
29 changes: 29 additions & 0 deletions Zend/tests/clone/clone_with_007.phpt
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
--TEST--
Clone with supports __clone
--FILE--
<?php

class Clazz {
public function __construct(
public string $foo,
public string $bar,
) { }

public function __clone() {
$this->foo = 'foo updated in __clone';
$this->bar = 'bar updated in __clone';
}
}

$c = new Clazz('foo', 'bar');

var_dump(clone($c, [ 'foo' => 'foo updated in clone-with' ]));

?>
--EXPECTF--
object(Clazz)#%d (2) {
["foo"]=>
string(25) "foo updated in clone-with"
["bar"]=>
string(22) "bar updated in __clone"
}
40 changes: 40 additions & 0 deletions Zend/tests/clone/clone_with_008.phpt
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
--TEST--
Clone with readonly
--FILE--
<?php

readonly class Clazz {
public function __construct(
public public(set) string $a,
public public(set) string $b,
) { }

public function __clone() {
$this->b = '__clone';
}
}

$c = new Clazz('default', 'default');

var_dump(clone($c, [ 'a' => "with" ]));

try {
var_dump(clone($c, [ 'b' => "with" ]));
} catch (Throwable $e) {
echo $e::class, ": ", $e->getMessage(), PHP_EOL;
}

?>
--EXPECTF--
object(Clazz)#%d (2) {
["a"]=>
string(4) "with"
["b"]=>
string(7) "__clone"
}
object(Clazz)#%d (2) {
["a"]=>
string(7) "default"
["b"]=>
string(4) "with"
}
72 changes: 72 additions & 0 deletions Zend/tests/clone/clone_with_009.phpt
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
--TEST--
Clone with lazy objects
--FILE--
<?php

class C {
public $a = 1;

public function __construct() {
}
}

function test(string $name, object $obj) {
printf("# %s:\n", $name);

$reflector = new ReflectionClass($obj::class);
$clone = clone($obj, [ 'a' => 2 ]);

var_dump($reflector->isUninitializedLazyObject($obj));
var_dump($obj);
var_dump($reflector->isUninitializedLazyObject($clone));
var_dump($clone);
}

$reflector = new ReflectionClass(C::class);

$obj = $reflector->newLazyGhost(function ($obj) {
var_dump("initializer");
$obj->__construct();
});

test('Ghost', $obj);

$obj = $reflector->newLazyProxy(function ($obj) {
var_dump("initializer");
return new C();
});

test('Proxy', $obj);

?>
--EXPECTF--
# Ghost:
string(11) "initializer"
bool(false)
object(C)#%d (1) {
["a"]=>
int(1)
}
bool(false)
object(C)#%d (1) {
["a"]=>
int(2)
}
# Proxy:
string(11) "initializer"
bool(false)
lazy proxy object(C)#%d (1) {
["instance"]=>
object(C)#%d (1) {
["a"]=>
int(1)
}
}
bool(false)
lazy proxy object(C)#%d (1) {
["instance"]=>
object(C)#%d (1) {
["a"]=>
int(2)
}
}
21 changes: 21 additions & 0 deletions Zend/tests/clone/clone_with_010.phpt
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
--TEST--
Clone with native classes
--FILE--
<?php

try {
var_dump(clone(new \Random\Engine\Secure(), [ 'with' => "something" ]));
} catch (Throwable $e) {
echo $e::class, ": ", $e->getMessage(), PHP_EOL;
}

try {
var_dump(clone(new \Random\Engine\Xoshiro256StarStar(), [ 'with' => "something" ]));
} catch (Throwable $e) {
echo $e::class, ": ", $e->getMessage(), PHP_EOL;
}

?>
--EXPECT--
Error: Trying to clone an uncloneable object of class Random\Engine\Secure
Error: Cannot create dynamic property Random\Engine\Xoshiro256StarStar::$with
18 changes: 18 additions & 0 deletions Zend/tests/clone/clone_with_011.phpt
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
--TEST--
Clone with name mangling
--FILE--
<?php

class Foo {
private string $bar = 'default';
}

try {
var_dump(clone(new Foo(), ["\0Foo\0bar" => 'updated']));
} catch (Throwable $e) {
echo $e::class, ": ", $e->getMessage(), PHP_EOL;
}

?>
--EXPECT--
Error: Cannot access property starting with "\0"
35 changes: 35 additions & 0 deletions Zend/tests/clone/clone_with_012.phpt
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
--TEST--
Clone with property hook updating readonly property
--FILE--
<?php

class Clazz {
public string $foo {
set {
$this->foo = $value;
$this->bar = 'bar updated in hook';
}
}

public public(set) readonly string $bar;
}

$f = new Clazz();

var_dump(clone($f, ['foo' => 'foo updated in clone-with']));

try {
var_dump(clone($f, ['foo' => 'foo updated in clone-with', 'bar' => 'bar updated in clone-with']));
} catch (Throwable $e) {
echo $e::class, ": ", $e->getMessage(), PHP_EOL;
}

?>
--EXPECTF--
object(Clazz)#%d (2) {
["foo"]=>
string(25) "foo updated in clone-with"
["bar"]=>
string(19) "bar updated in hook"
}
Error: Cannot modify readonly property Clazz::$bar
31 changes: 31 additions & 0 deletions Zend/tests/clone/clone_with_013.phpt
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
--TEST--
Clone with references
--FILE--
<?php

$x = new stdClass();

$ref = 'reference';
$with = ['x' => &$ref];

try {
var_dump(clone($x, $with));
} catch (Throwable $e) {
echo $e::class, ": ", $e->getMessage(), PHP_EOL;
}

unset($ref);

try {
var_dump(clone($x, $with));
} catch (Throwable $e) {
echo $e::class, ": ", $e->getMessage(), PHP_EOL;
}

?>
--EXPECTF--
Error: Cannot assign by reference when cloning with updated properties
object(stdClass)#%d (1) {
["x"]=>
string(9) "reference"
}
16 changes: 14 additions & 2 deletions Zend/zend_builtin_functions.c
Original file line number Diff line number Diff line change
@@ -72,9 +72,12 @@ zend_result zend_startup_builtin_functions(void) /* {{{ */
ZEND_FUNCTION(clone)
{
zend_object *zobj;
HashTable *with = (HashTable*)&zend_empty_array;

ZEND_PARSE_PARAMETERS_START(1, 1)
ZEND_PARSE_PARAMETERS_START(1, 2)
Z_PARAM_OBJ(zobj)
Z_PARAM_OPTIONAL
Z_PARAM_ARRAY_HT(with)
ZEND_PARSE_PARAMETERS_END();

/* clone() also exists as the ZEND_CLONE OPcode and both implementations must be kept in sync. */
@@ -95,7 +98,16 @@ ZEND_FUNCTION(clone)
}

zend_object *cloned;
cloned = zobj->handlers->clone_obj(zobj);
if (zend_hash_num_elements(with) > 0) {
if (UNEXPECTED(!zobj->handlers->clone_obj_with)) {
zend_throw_error(NULL, "Cloning objects of class %s with updated properties is not supported", ZSTR_VAL(ce->name));
RETURN_THROWS();
}

cloned = zobj->handlers->clone_obj_with(zobj, scope, with);
} else {
cloned = zobj->handlers->clone_obj(zobj);
}

ZEND_ASSERT(cloned || EG(exception));
if (EXPECTED(cloned)) {
2 changes: 1 addition & 1 deletion Zend/zend_builtin_functions.stub.php
Original file line number Diff line number Diff line change
@@ -8,7 +8,7 @@ class stdClass
}

/** @refcount 1 */
function _clone(object $object): object {}
function _clone(object $object, array $withProperties = []): object {}

function exit(string|int $status = 0): never {}

3 changes: 2 additions & 1 deletion Zend/zend_builtin_functions_arginfo.h

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Zend/zend_iterators.c
Original file line number Diff line number Diff line change
@@ -31,6 +31,7 @@ static const zend_object_handlers iterator_object_handlers = {
iter_wrapper_free,
iter_wrapper_dtor,
NULL, /* clone_obj */
NULL, /* clone_obj_with */
NULL, /* prop read */
NULL, /* prop write */
NULL, /* read dim */
1 change: 1 addition & 0 deletions Zend/zend_object_handlers.c
Original file line number Diff line number Diff line change
@@ -2541,6 +2541,7 @@ ZEND_API const zend_object_handlers std_object_handlers = {
zend_object_std_dtor, /* free_obj */
zend_objects_destroy_object, /* dtor_obj */
zend_objects_clone_obj, /* clone_obj */
zend_objects_clone_obj_with, /* clone_obj_with */

zend_std_read_property, /* read_property */
zend_std_write_property, /* write_property */
2 changes: 2 additions & 0 deletions Zend/zend_object_handlers.h
Original file line number Diff line number Diff line change
@@ -180,6 +180,7 @@ typedef void (*zend_object_free_obj_t)(zend_object *object);
typedef void (*zend_object_dtor_obj_t)(zend_object *object);

typedef zend_object* (*zend_object_clone_obj_t)(zend_object *object);
typedef zend_object* (*zend_object_clone_obj_with_t)(zend_object *object, const zend_class_entry *scope, const HashTable *properties);

/* Get class name for display in var_dump and other debugging functions.
* Must be defined and must return a non-NULL value. */
@@ -209,6 +210,7 @@ struct _zend_object_handlers {
zend_object_free_obj_t free_obj; /* required */
zend_object_dtor_obj_t dtor_obj; /* required */
zend_object_clone_obj_t clone_obj; /* optional */
zend_object_clone_obj_with_t clone_obj_with; /* optional */
zend_object_read_property_t read_property; /* required */
zend_object_write_property_t write_property; /* required */
zend_object_read_dimension_t read_dimension; /* required */
46 changes: 46 additions & 0 deletions Zend/zend_objects.c
Original file line number Diff line number Diff line change
@@ -276,6 +276,52 @@ ZEND_API void ZEND_FASTCALL zend_objects_clone_members(zend_object *new_object,
}
}

ZEND_API zend_object *zend_objects_clone_obj_with(zend_object *old_object, const zend_class_entry *scope, const HashTable *properties)
{
zend_object *new_object = old_object->handlers->clone_obj(old_object);

if (EXPECTED(!EG(exception))) {
/* Unlock readonly properties once more. */
if (ZEND_CLASS_HAS_READONLY_PROPS(new_object->ce)) {
for (uint32_t i = 0; i < new_object->ce->default_properties_count; i++) {
zval* prop = OBJ_PROP_NUM(new_object, i);
Z_PROP_FLAG_P(prop) |= IS_PROP_REINITABLE;
}
}

const zend_class_entry *old_scope = EG(fake_scope);

EG(fake_scope) = scope;

ZEND_HASH_FOREACH_KEY_VAL(properties, zend_ulong num_key, zend_string *key, zval *val) {
if (UNEXPECTED(Z_ISREF_P(val))) {
if (Z_REFCOUNT_P(val) == 1) {
val = Z_REFVAL_P(val);
} else {
zend_throw_error(NULL, "Cannot assign by reference when cloning with updated properties");
break;
}
}

if (UNEXPECTED(key == NULL)) {
key = zend_long_to_str(num_key);
new_object->handlers->write_property(new_object, key, val, NULL);
zend_string_release_ex(key, false);
} else {
new_object->handlers->write_property(new_object, key, val, NULL);
}

if (UNEXPECTED(EG(exception))) {
break;
}
} ZEND_HASH_FOREACH_END();

EG(fake_scope) = old_scope;
}

return new_object;
}

ZEND_API zend_object *zend_objects_clone_obj(zend_object *old_object)
{
zend_object *new_object;
1 change: 1 addition & 0 deletions Zend/zend_objects.h
Original file line number Diff line number Diff line change
@@ -30,6 +30,7 @@ ZEND_API void ZEND_FASTCALL zend_objects_clone_members(zend_object *new_object,
ZEND_API void zend_object_std_dtor(zend_object *object);
ZEND_API void zend_objects_destroy_object(zend_object *object);
ZEND_API zend_object *zend_objects_clone_obj(zend_object *object);
ZEND_API zend_object *zend_objects_clone_obj_with(zend_object *object, const zend_class_entry *scope, const HashTable *properties);

void zend_object_dtor_dynamic_properties(zend_object *object);
void zend_object_dtor_property(zend_object *object, zval *p);
3 changes: 2 additions & 1 deletion Zend/zend_vm_def.h
Original file line number Diff line number Diff line change
@@ -6006,7 +6006,8 @@ ZEND_VM_COLD_CONST_HANDLER(110, ZEND_CLONE, CONST|TMPVAR|UNUSED|THIS|CV, ANY)
SAVE_OPLINE();
obj = GET_OP1_OBJ_ZVAL_PTR_UNDEF(BP_VAR_R);

/* ZEND_CLONE also exists as the clone() function and both implementations must be kept in sync. */
/* ZEND_CLONE also exists as the clone() function and both implementations must be kept in sync.
* The OPcode intentionally does not support a clone-with property list to keep it simple. */

do {
if (OP1_TYPE == IS_CONST ||
12 changes: 8 additions & 4 deletions Zend/zend_vm_execute.h

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions ext/com_dotnet/com_handlers.c
Original file line number Diff line number Diff line change
@@ -514,6 +514,7 @@ zend_object_handlers php_com_object_handlers = {
php_com_object_free_storage,
zend_objects_destroy_object,
php_com_object_clone,
NULL, /* clone_with */
com_property_read,
com_property_write,
com_read_dimension,
1 change: 1 addition & 0 deletions ext/com_dotnet/com_saproxy.c
Original file line number Diff line number Diff line change
@@ -402,6 +402,7 @@ zend_object_handlers php_com_saproxy_handlers = {
saproxy_free_storage,
zend_objects_destroy_object,
saproxy_clone,
NULL, /* clone_with */
saproxy_property_read,
saproxy_property_write,
saproxy_read_dimension,