PHP 8.5’s Clone-With Syntax: Cleaner Immutable DTOs in Laravel

Readonly properties were the right idea. Cloning them was the wrong experience.

PHP 8.1 gave us readonly properties and the developer community immediately embraced them for DTOs, value objects, and domain models. Immutable-by-default objects felt like the right direction. The problem showed up the moment you needed a slightly modified copy of one.

You couldn’t just change a readonly property after initialization — that’s the whole point. So you’d rebuild the object from scratch, or write a verbose with*() method for every property, or reach for reflection hacks that defeated immutability entirely.

PHP 8.5’s clone with syntax solves this cleanly. One expression. No boilerplate. No reflection. Your readonly classes stay truly immutable, and your code stays readable.


The Problem: Immutability Was Verbose to Maintain

Here’s a typical OrderData DTO in a Laravel application before PHP 8.5:

final readonly class OrderData
{
    public function __construct(
        public readonly int    $id,
        public readonly string $status,
        public readonly float  $total,
        public readonly string $currency,
        public readonly string $customerEmail,
    ) {}
}

Now imagine you need a copy of this DTO with just the status changed to 'shipped'. Your options before PHP 8.5 were painful:

Option 1: Rebuild from scratch

$shipped = new OrderData(
    id:            $order->id,
    status:        'shipped',       // changed
    total:         $order->total,
    currency:      $order->currency,
    customerEmail: $order->customerEmail,
);

This works, but it doesn’t scale. Add more properties and this becomes error-prone — it’s easy to accidentally skip one.

Option 2: Wither methods

public function withStatus(string $status): self
{
    return new self(
        id:            $this->id,
        status:        $status,
        total:         $this->total,
        currency:      $this->currency,
        customerEmail: $this->customerEmail,
    );
}

Now you need a withStatus(), withTotal(), withCurrency() for every property you might want to change. Boilerplate multiplies with every property you add.

Option 3: Reflection (never do this)

$reflection = new ReflectionProperty($order, 'status');
$reflection->setValue($clone, 'shipped'); // 😱 breaks readonly contract

This defeats the purpose entirely and makes static analysis meaningless.


The Solution: clone with

PHP 8.5 introduces clone $object with ['property' => $newValue]. It creates a full copy of the object and overrides only the properties you specify, in a single expression:

$shipped = clone $order with ['status' => 'shipped'];

The original $order is untouched. $shipped is an identical copy with only status changed. All other readonly properties carry over automatically.

Multiple properties at once:

$refunded = clone $order with [
    'status'   => 'refunded',
    'total'    => 0.0,
];

Clean, declarative, and immediately readable. You can see exactly what changed without scanning a constructor call.


Real Laravel Examples

Immutable DTO in a Service Pipeline

This is where clone with shines. You have a DTO flowing through a chain of service classes, each one transforming a piece of it:

final readonly class OrderData
{
    public function __construct(
        public readonly int    $id,
        public readonly string $status,
        public readonly float  $total,
        public readonly string $currency,
        public readonly string $customerEmail,
        public readonly ?string $trackingNumber = null,
        public readonly ?Carbon $shippedAt = null,
    ) {}
}

// In your OrderFulfillmentService
class OrderFulfillmentService
{
    public function markAsShipped(OrderData $order, string $trackingNumber): OrderData
    {
        return clone $order with [
            'status'         => 'shipped',
            'trackingNumber' => $trackingNumber,
            'shippedAt'      => now(),
        ];
    }

    public function applyDiscount(OrderData $order, float $discountPercent): OrderData
    {
        return clone $order with [
            'total' => round($order->total * (1 - $discountPercent / 100), 2),
        ];
    }
}

Each method is a pure transformation — takes an OrderData, returns a new OrderData, touches nothing else. No side effects. No mutation. Easy to test in isolation.


Value Objects in Domain Models

Value objects like Money, Address, and DateRange are natural fits for clone with:

final readonly class Money
{
    public function __construct(
        public readonly float  $amount,
        public readonly string $currency,
    ) {}

    public function add(float $amount): self
    {
        return clone $this with ['amount' => $this->amount + $amount];
    }

    public function convertTo(string $currency, float $rate): self
    {
        return clone $this with [
            'amount'   => round($this->amount * $rate, 2),
            'currency' => $currency,
        ];
    }

    public function format(): string
    {
        return number_format($this->amount, 2) . ' ' . $this->currency;
    }
}

// Usage in a checkout service
$price    = new Money(100.00, 'USD');
$taxed    = $price->add(8.50);          // Money(108.50, 'USD')
$inEuros  = $taxed->convertTo('EUR', 0.92); // Money(99.82, 'EUR')

Every operation returns a new Money object. The original is never modified. The chain reads naturally.


Configuration Objects

clone with is excellent for building configuration variants from a base config:

final readonly class MailConfig
{
    public function __construct(
        public readonly string $driver,
        public readonly string $host,
        public readonly int    $port,
        public readonly string $encryption,
        public readonly bool   $debug = false,
    ) {}
}

// Base config from your service provider
$baseConfig = new MailConfig(
    driver:     'smtp',
    host:       config('mail.host'),
    port:       587,
    encryption: 'tls',
);

// Test config: same base, debug mode on, different port
$testConfig = clone $baseConfig with [
    'port'  => 1025,
    'debug' => true,
];

// Sendmail variant for CLI commands
$cliConfig = clone $baseConfig with ['driver' => 'sendmail'];

One base config object. Three variants. No duplication. No factory methods.


What clone with Enforces

PHP’s clone with isn’t a free-for-all. It respects the same rules as regular assignment:

Type safety — You can’t assign a wrong type to a typed property:

// TypeError: Cannot assign string to int
$broken = clone $order with ['id' => 'not-an-int'];

Visibility — You can only modify properties you’d normally have access to from the current scope:

// Error: Cannot access private property from outside class
$broken = clone $order with ['privateField' => 'value'];

Readonly contract — You can only set readonly properties from within the class scope (via a method), not from outside:

// This works — inside the class
public function withStatus(string $s): self
{
    return clone $this with ['status' => $s];
}

// This fails — from outside the class
$broken = clone $order with ['status' => 'shipped']; // Error if status is private readonly

This is exactly the right behavior. clone with gives you ergonomic immutability without breaking the readonly contract.


The Wither Pattern: Best of Both Worlds

With clone with, you can write concise with*() methods that expose a clean API while using the new syntax internally:

final readonly class OrderData
{
    public function __construct(
        public readonly int     $id,
        public readonly string  $status,
        public readonly float   $total,
        public readonly string  $currency,
        public readonly string  $customerEmail,
        public readonly ?string $trackingNumber = null,
    ) {}

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

    public function withTracking(string $trackingNumber): self
    {
        return clone $this with ['trackingNumber' => $trackingNumber];
    }

    public function withTotal(float $total): self
    {
        return clone $this with ['total' => $total];
    }
}

// Fluid, chainable updates
$processed = $order
    ->withStatus('processing')
    ->withTotal(89.99)
    ->withTracking('1Z999AA10123456784');

Each with*() method is a one-liner. Adding a new property to the DTO means adding one line to the constructor and one with*() method — not updating five different factory methods.


Combine With the Pipe Operator

If you also upgraded to PHP 8.5’s pipe operator (from Day 2 of this series), you can compose transformations elegantly:

$result = $order
    |> (fn($o) => clone $o with ['status' => 'processing'])
    |> (fn($o) => clone $o with ['total' => applyTax($o->total)])
    |> (fn($o) => clone $o with ['trackingNumber' => generateTracking($o->id)]);

Left-to-right, step-by-step, no mutation. This is functional-style data transformation in plain PHP.


Should You Migrate Existing DTOs?

If you’re on PHP 8.5, the migration path for existing DTOs is simple:

  1. Add readonly to the class declaration if all properties are already readonly
  2. Replace verbose with*() methods with clone $this with [...] one-liners
  3. Delete any factory methods or reflection workarounds you wrote to handle cloning

Start with your most frequently used DTOs — the ones that flow through multiple service layers. The brevity improvement is immediately apparent and the static analysis story gets better: Psalm and PHPStan both understand clone with and will flag type mismatches at analysis time, not runtime.


The Bigger Picture

clone with isn’t just a convenience feature. It removes the last friction point from a design pattern — immutable DTOs — that makes Laravel applications dramatically easier to reason about, test, and debug.

When data can’t change unexpectedly, bugs that arise from shared mutable state disappear. Service methods become pure functions. Tests become simpler. Pipelines become predictable.

PHP has been building toward this for four releases. readonly properties in 8.1. readonly classes in 8.2. __clone() improvements in 8.3. clone with in 8.5. The language is telling you something: immutability is the direction.

Your DTOs should be listening.


Follow me for daily deep-dives on Laravel, PHP, Vue.js, and AI integrations. New article every day.

Leave a Reply

Your email address will not be published. Required fields are marked *