Table of Contents

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, within a class changes can be localized in appropriate methods rather than 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 a second array parameter.

function clone(object $object, array $withProperties = []): object {}

This allows all the following syntax examples to be valid:

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

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

Technical Details

A userland implementation of this would be replacing the clone call with the following code:

<?php
 
$cloned = clone $object;
foreach ($withProperties as $key => $value) {
    $cloned->{$key} = $value;
}
return $cloned;

The clone keyword (and T_CLONE token) will be kept and it will remain impossible to create a clone function within a namespace. It will also not be possible to disable the clone() function with the disable_functions=clone INI setting.

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 previously proposed with keyword, as we don't feel the scope of the feature warrants any BC impact.

Likewise, all syntax has to exist in PHP already, to not add cognitive load to readers when they come across it. A simple PHP array feels like the most ubiquitous syntax in PHP.

Furthermore, this RFC considers touching how __clone works (e.g. by adding parameters to it) to be a non-goal due to the increased complexity and BC considerations.

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($newC) {
        return clone($this, ["c" => $newC]);
    }
}
 
$x = new Foo();
// 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(5));

Visibility rules

Public properties can be changed from the outside during cloning.

<?php
 
class Foo {
    public int $pub = 1;
}
 
$foo = new Foo();
// object(Foo)#2 (1) { ["pub"]=> int(5) }
var_dump(clone($foo, ["pub" => 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();
// object(Foo)#2 (1) { ["pub"]=> int(5) }
var_dump($foo->withPub(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 unchanged by this RFC. Changing the visibility of readonly properties is outside the scope of this RFC, and this example is provided for sake of completeness 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); // object(Foo)#1 (1) { ["c"]=> int(1) }
var_dump(clone($x, ["c" => 2])); // object(Foo)#2 (1) { ["c"]=> int(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
// Note that this is how readonly properties work in PHP currently and any change would be outside the scope of this RFC

Dynamic Properties

Since property assignments are made just as a regular assignment would be, dynamic property creation follows established PHP rules:

<?php
 
class Foo {
}
 
 
$x = new Foo();
// Deprecated: Creation of dynamic property Foo::$propertyThatDoesNotExist is deprecated in ...
// object(Foo)#2 (1) { ["propertyThatDoesNotExist"]=> int(2) }
var_dump(clone($x, ["propertyThatDoesNotExist" => 2]));
 
#[AllowDynamicProperties]
class Bar {
}
 
$y = new Bar();
// object(Bar)#3 (1) { ["propertyThatDoesNotExist"]=> int(2) }
var_dump(clone($y, ["propertyThatDoesNotExist" => 2]));

Numeric Arrays

Given the function signature clone(object $object, array $withProperties): object {} it follows that numeric parameters derive their key from their implicit position as they would for any other PHP array. For clone this is usually not useful and is documented here for completeness.

<?php
 
var_dump(
    clone(new stdClass, [1, 2])
);
 
// object(stdClass)#2 (2) {
//   ["0"]=>
//   int(1)
//   ["1"]=>
//   int(2)
// }

Omitting the array keys for most objects will lead to deprecation notices, as shown in the “Dynamic Properties” section.

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

None

Unaffected PHP Functionality

Future Scope

Proposed Voting Choices

Implement clone-with as outlined in the RFC?
Real name Yes No
alec (alec)  
asgrim (asgrim)  
beberlei (beberlei)  
crell (crell)  
edorian (edorian)  
kalle (kalle)  
ocramius (ocramius)  
seld (seld)  
sergey (sergey)  
theodorejb (theodorejb)  
timwolla (timwolla)  
Count: 8 3

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.

Changelog

Earlier versions of this RFC proposed a clone method with named-parameters. This was dropped in favor of less complex functions with more familiar syntax and no newly invented concepts not seen anywhere else in php-src. The RFC, the implementation and the documentation efforts needed were significantly reduced by this change.