skip to content

In PHP 8.5, how does clone() with its $withProperties argument work, and why does it suit wither methods on readonly classes?

level: seniorimportance: should knowfreq 25%

answer

  1. clone became a function in 8.5
  2. array of property name to new value
  3. applied after __clone() runs
  4. visibility and set hooks still apply
  5. readonly writable once more on the copy

basics

~10 s

Since PHP 8.5, clone($obj, ['prop' => $value]) shallow-copies the object, runs __clone(), then assigns the listed properties on the copy, even readonly ones, respecting visibility and set hooks. Withers become one line.

solid answer

~50 s

PHP 8.5 turned `clone` into a function, `clone(object $object, array $withProperties = []): object`; `clone $x` still works. The engine makes the usual shallow copy, runs `__clone()` if defined, and then assigns each entry of `$withProperties` to the copy, in array order. Those assignments behave like normal writes from the **calling scope**: visibility and asymmetric visibility are checked, and `set` hooks run. The special part is **readonly**: a readonly property that is already initialised may be overwritten on the copy, and it is readonly again afterwards. That makes a wither on a readonly class a one-liner: `return clone($this, ['total' => $total]);`. Called from outside the class, the same override fails, because a readonly property is implicitly `protected(set)` since PHP 8.4. Before 8.5 you rebuilt the object with `new static(...)` or reinitialised readonly properties in an argument-less `__clone()`.

code

php · 26 lines
php
<?php
declare(strict_types=1);

final readonly class QuoteDraft
{
    public function __construct(
        public string $customer,
        public int $totalCents,
        public DateTimeImmutable $validUntil,
    ) {}

    public function withTotal(int $totalCents): static
    {
        return clone($this, ['totalCents' => $totalCents]);
    }
}

$draft = new QuoteDraft('ACME', 10_000, new DateTimeImmutable('2026-10-01'));
$revised = $draft->withTotal(12_000);
echo $draft->totalCents, ' ', $revised->totalCents, PHP_EOL; // 10000 12000

try {
    clone($draft, ['totalCents' => 1]); // outside the class
} catch (Error $e) {
    echo $e->getMessage(), PHP_EOL; // Cannot modify protected(set) readonly property ...
}

go deeper

for a junior

Recall that PHP 8.5 lets clone() take an array of property names and new values for the copy.

for a middle

Explain the order: shallow copy, __clone(), then overrides, with visibility, hooks and types enforced from the calling scope.

for a senior

Use it for withers on readonly classes, know why outside callers are blocked by implicit protected(set), and remember the copy is still shallow.

for a principal

Weigh adopting 8.5 withers across a codebase against keeping explicit constructors, considering runtime-checked property names and static analysis support.

## The feature PHP 8.5 (the "clone with" RFC) made `clone` usable as a **function** with an optional second argument: ```php function clone(object $object, array $withProperties = []): object ``` `clone $quote` and `clone($quote)` behave exactly as before. With a non-empty array, the copy receives the listed property values. The array maps property names to values: `clone($quote, ['total' => 120, 'status' => Status::Revised])`. ## Order of operations 1. The engine makes a **shallow copy** of `$object` (no constructor runs). 2. If the class defines `__clone()`, it runs on the copy. 3. Each entry of `$withProperties` is assigned to the copy, **in array order**. Because overrides come last, they win over anything `__clone()` set. If a `set` hook or a type check throws in step 3, the whole `clone()` call throws and the partial copy is discarded. ## What the overrides respect The assignments are ordinary property writes performed from the scope that called `clone()`: - **Visibility** — from outside the class you cannot override a `protected` or `private` property; you get, for example, `Cannot access protected property ...`. - **Asymmetric visibility** — a `private(set)` property can only be overridden from its own class; a `readonly` property is implicitly `protected(set)` since PHP 8.4, so outside code gets `Cannot modify protected(set) readonly property Quote::$total from global scope`. - **Property hooks** — a `set` hook runs for the override, so validation and normalisation still happen. - **Types** — typed properties still check the assigned value, so a wrong type throws a `TypeError`. - **References** — an array element that is a PHP reference is rejected with `Cannot assign by reference when cloning with updated properties`. - **Unknown names** — behave like assigning an undeclared property: allowed on `stdClass`, deprecated on an ordinary class, an `Error` where dynamic properties are forbidden. ## The readonly exception Normally a readonly property can be written once, when it is initialised. `clone()` with overrides may write it **again on the copy**, even though it was already set on the original. After `clone()` returns, the property is readonly again, so the copy is as immutable as the original. This is exactly the shape of a **wither**: a method that returns a modified copy of an immutable object. | Approach | Works on readonly? | Cost | |---|---|---| | `new static($a, $b, $newC, ...)` | yes | repeats every constructor argument; breaks when fields are added | | `__clone()` reinitialising (8.3+) | yes, once per property | `__clone()` takes no arguments, so passing the new value needs a workaround | | `clone($this, ['c' => $newC])` (8.5) | yes | one line; only the changed property is named | ## Limits worth knowing - It is still a **shallow** copy. `clone($quote, ['total' => 120])` shares every object-valued property it did not override. - Keys are plain property names as strings; they are not checked at compile time, so a typo becomes a runtime error or a dynamic property. - Uncloneable objects (enum cases, `PDO`, `Generator`) still throw `Trying to clone an uncloneable object of class ...`. - Because `clone` is now a function, `clone(...)` gives a first-class callable, and `array_map(clone(...), $lines)` copies each object in an array. ## The workarounds it replaces Before 8.5, a readonly `QuoteDraft::withTotal()` had two options. It could call `new static($this->customer, $totalCents, $this->validUntil)`, repeating every constructor argument and breaking silently when a subclass changed its constructor. Or, from PHP 8.3, it could clone and let `__clone()` reinitialise the property, but `__clone()` receives no arguments, so the new total had to reach it through some side channel. `clone($this, ['totalCents' => $totalCents])` names only what changes and keeps the object readonly throughout, which is why withers are the headline use of the feature. ## Summary `clone()` with `$withProperties` is "shallow copy, run `__clone()`, then assign these properties as if from here, readonly included". Put it inside the class, in withers, where visibility allows the write.

  • If both `__clone()` and `$withProperties` set the same property, which value does the copy keep?
    The `$withProperties` value. `clone()` runs `__clone()` first and applies the overrides afterwards, so the override wins, even for a readonly property that `__clone()` already reinitialised.
  • Can a caller outside a readonly class use `clone($obj, [...])` to change a public readonly property?
    Not by default. A readonly property is implicitly `protected(set)` since PHP 8.4, and the override is a write from the caller's scope, so it throws `Cannot modify protected(set) readonly property ... from global scope`. Declaring the property `public(set) readonly` would allow it, which usually defeats the purpose; keep withers inside the class.

saying these in an interview costs you the question

  • clone() with overrides skips visibility checks because it runs inside the engine.
  • The overrides are applied before __clone() runs.
  • clone-with performs a deep copy of object properties.
  • After clone() the overridden readonly property stays writable on the copy.
  • The old clone $obj syntax was removed in PHP 8.5.