DEV Community

Cover image for PHP 8.5 clone with: Immutable Updates Without a withX() Explosion
Gabriel Anhaia
Gabriel Anhaia

Posted on

PHP 8.5 clone with: Immutable Updates Without a withX() Explosion


Open any value object in a codebase that took immutability seriously. You'll find a wall of near-identical methods. withStatus(), withEmail(), withPlan(), withCanceledAt(). Each one does the same thing: build a fresh instance, copy every field, swap one. Add a property to the constructor and you go edit all of them, because every wither re-lists every argument. Miss one and you ship a copy that silently drops a field.

PHP 8.5 shipped clone with, and it retires that wall. The clone_with_v2 RFC turns clone into something that can override properties in the same expression, readonly ones included. Here's the before, the after, and the two semantics that decide whether you can delete a wither entirely or keep a one-liner.

The withX() explosion

Here's a readonly subscription value object on PHP 8.4. Four fields, three withers.

final class Subscription
{
    public function __construct(
        public readonly SubscriptionId $id,
        public readonly Status $status,
        public readonly Plan $plan,
        public readonly ?DateTimeImmutable $canceledAt,
    ) {}

    public function withStatus(Status $status): self
    {
        return new self(
            $this->id,
            $status,
            $this->plan,
            $this->canceledAt,
        );
    }

    public function withPlan(Plan $plan): self
    {
        return new self(
            $this->id,
            $this->status,
            $plan,
            $this->canceledAt,
        );
    }

    public function withCanceledAt(
        DateTimeImmutable $at,
    ): self {
        return new self(
            $this->id,
            $this->status,
            $this->plan,
            $at,
        );
    }
}
Enter fullscreen mode Exit fullscreen mode

Every method repeats the full constructor call. The signal-to-noise ratio is terrible: the interesting part of withPlan() is one argument, wrapped in three lines of copying. And the whole block is fragile. The day you add a trialEndsAt field, you edit the constructor plus all three withers, and nothing warns you if you forget one.

Why readonly made it worse

The obvious escape before 8.5 was clone plus mutate:

$copy = clone $this;
$copy->status = $status; // fails on readonly
Enter fullscreen mode Exit fullscreen mode

That works for a plain property. It does not work here. A readonly property can be written once, in the scope that declared it, and never again. Assigning to $copy->status after construction throws Cannot modify readonly property. PHP 8.3 opened a narrow door by letting you reinitialize readonly properties inside __clone(), but that meant pushing the new value into the object through instance state before the clone, which is its own kind of ugly. So teams that wanted real immutability kept re-listing constructor arguments in every wither. The explosion was the price of readonly.

clone with, the 8.5 way

clone now takes an optional second argument: an associative array of property names to new values. It clones the object, then assigns those properties on the copy.

$next = clone($this, ['status' => $status]);
Enter fullscreen mode Exit fullscreen mode

The full set of accepted forms:

$y = clone $x;                    // still valid
$y = clone($x);                   // call style
$y = clone($x, []);               // empty override
$y = clone($x, ['plan' => $p]);   // one override
$y = clone($x, $changes);         // array variable
Enter fullscreen mode Exit fullscreen mode

The signature is clone(object $object, array $withProperties = []): object. Now the subscription withers collapse:

final class Subscription
{
    public function __construct(
        public readonly SubscriptionId $id,
        public readonly Status $status,
        public readonly Plan $plan,
        public readonly ?DateTimeImmutable $canceledAt,
    ) {}

    public function withStatus(Status $status): self
    {
        return clone($this, ['status' => $status]);
    }

    public function withPlan(Plan $plan): self
    {
        return clone($this, ['plan' => $plan]);
    }

    public function withCanceledAt(
        DateTimeImmutable $at,
    ): self {
        return clone($this, ['canceledAt' => $at]);
    }
}
Enter fullscreen mode Exit fullscreen mode

Add trialEndsAt now and the existing withers keep working untouched. Each one names only the field it changes. The copying is gone.

The semantics that decide "keep or delete"

Two rules from the RFC decide whether you can drop the wither entirely and call clone with at the call site, or keep it as a one-line method.

Assignments respect visibility. The override array writes each property as if you'd assigned it by hand at that spot. Visibility rules for property access apply. For a readonly property, a normal assignment is only legal inside the declaring class, so clone($this, ['status' => ...]) works inside Subscription, and this fails from a controller:

// outside the class, on readonly $status
$paused = clone($sub, ['status' => Status::Paused]);
// Error: Cannot modify readonly property
Enter fullscreen mode Exit fullscreen mode

That is the answer to "can I delete the wither?" For readonly fields, no. The write has to happen inside the class, so the wither method stays, but it's a single line instead of a full constructor call. If the property is instead public private(set) (PHP 8.4 asymmetric visibility), the same outside-the-class rule applies. Only a genuinely public, writable property lets an external caller run clone with directly.

__clone() runs first. If your object defines __clone(), it executes before the override array is applied. That ordering matters for deep copies. You deep-clone a nested mutable object in __clone(), then the overrides land on top:

final class Invoice
{
    public function __construct(
        public readonly InvoiceId $id,
        public Money $total,
        public LineItems $items,
    ) {}

    public function __clone(): void
    {
        $this->items = clone $this->items;
    }

    public function withTotal(Money $total): self
    {
        return clone($this, ['total' => $total]);
    }
}
Enter fullscreen mode Exit fullscreen mode

withTotal() gets a copy whose items is a fresh LineItems (from __clone()) and whose total is the new value (from the override). The two mechanisms compose instead of fighting.

Overrides are applied in order, and can be partial

The properties are assigned in array iteration order. If one assignment throws (a set hook rejects the value, or a type mismatch), the remaining assignments don't run. The clone already exists at that point; it's the local variable you were building. So treat the override array as a sequence of ordinary assignments, not an atomic transaction. In practice you're returning the result directly from a wither, so a thrown exception means nothing escapes anyway.

This is why clone with is not a validation layer. It writes values; it doesn't check them beyond the property's own type and hooks.

When you still want a real wither

clone with replaces the mechanical withers, the ones that only copy and swap. It does not replace withers that do work.

Keep a hand-written method when the update has to enforce an invariant:

public function cancel(DateTimeImmutable $at): self
{
    if ($this->status === Status::Canceled) {
        throw new AlreadyCanceled($this->id);
    }

    return clone($this, [
        'status' => Status::Canceled,
        'canceledAt' => $at,
    ]);
}
Enter fullscreen mode Exit fullscreen mode

Here the method name carries intent (cancel, not withStatus), it guards a transition, and it sets two fields together so you can't produce a canceled subscription with a null timestamp. clone with is the engine inside it. The domain rule is the reason the method exists.

That's the line to hold. Use clone with to delete the withers that were pure boilerplate. Keep the methods that name a real operation and protect a real rule. A value object should read like a list of the things it lets you do, not a list of its properties spelled backwards with with in front.


If this was useful

clone with is a small language feature that pushes you toward a good habit: immutable domain objects that update through named operations, not setters. Keeping that logic in the value object, at the edge of the domain, instead of scattering copy-and-mutate code through your services, is exactly the boundary discipline that survives a framework swap. That separation between domain types and the framework around them is what Decoupled PHP is about.

Decoupled PHP — Clean and Hexagonal Architecture for Applications That Outlive the Framework

Available on Kindle, Paperback, and Hardcover. English, German, and Japanese editions out now — Portuguese and Spanish coming soon.

Top comments (0)