In PHP 8.5, how does clone() with its $withProperties argument work, and why does it suit wither methods on readonly classes?
answer
- clone became a function in 8.5
- array of property name to new value
- applied after __clone() runs
- visibility and set hooks still apply
- readonly writable once more on the copy
basics
~10 sSince 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 sPHP 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
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
Recall that PHP 8.5 lets clone() take an array of property names and new values for the copy.
Explain the order: shallow copy, __clone(), then overrides, with visibility, hooks and types enforced from the calling scope.
Use it for withers on readonly classes, know why outside callers are blocked by implicit protected(set), and remember the copy is still shallow.
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.