advanced php language

PHP 8.5 Clone With Changed Properties

PHP example
<?php

declare(strict_types=1);

final class Product
{
    public function __construct(
        public string $name,
        public int $priceCents,
    ) {
    }
}

$original = new Product('Notebook', 1299);
$discounted = clone($original, ['priceCents' => 999]);

echo $original->priceCents . PHP_EOL;
echo $discounted->priceCents . PHP_EOL;

// Prints:
// 1299
// 999

The original object is unchanged. The clone starts as a copy, then PHP applies the listed property changes to the cloned object.

Why This Matters

This feature is especially useful for immutable or value-object style classes. Instead of changing an existing object, you create a new object with one or two changed values.

PHP example
<?php

declare(strict_types=1);

readonly class Money
{
    public function __construct(
        public int $amountCents,
        public string $currency,
    ) {
        if ($amountCents < 0) {
            throw new InvalidArgumentException('Money cannot be negative.');
        }
    }

    public function withAmountCents(int $amountCents): self
    {
        if ($amountCents < 0) {
            throw new InvalidArgumentException('Money cannot be negative.');
        }

        return clone($this, ['amountCents' => $amountCents]);
    }
}

$price = new Money(1299, 'GBP');
$salePrice = $price->withAmountCents(999);

echo $price->amountCents . PHP_EOL;
echo $salePrice->amountCents . PHP_EOL;

// Prints:
// 1299
// 999

The method name makes the business operation readable, while clone($this, [...]) keeps the implementation short.

It Is Still A Clone

Cloning in PHP is shallow unless your class customises it. Object properties still point to the same nested objects unless you explicitly clone them too.

PHP example
<?php

declare(strict_types=1);

final class Customer
{
    public function __construct(
        public string $email,
    ) {
    }
}

final class OrderDraft
{
    public function __construct(
        public Customer $customer,
        public string $status,
    ) {
    }
}

$customer = new Customer('ada@example.com');
$draft = new OrderDraft($customer, 'basket');
$submitted = clone($draft, ['status' => 'submitted']);

$submitted->customer->email = 'new@example.com';

echo $draft->customer->email . PHP_EOL;
echo $submitted->customer->email . PHP_EOL;

// Prints:
// new@example.com
// new@example.com

Both order objects still reference the same customer object. That is normal PHP clone behaviour, and it is important in reviews.

Validation Still Belongs Somewhere

Changing properties during clone does not automatically run your constructor. If a property needs a rule, enforce it before cloning, inside a named with-er method, or through another method that owns the invariant.

PHP example
<?php

declare(strict_types=1);

readonly class Pagination
{
    public function __construct(
        public int $page,
        public int $perPage,
    ) {
        $this->assertValid($page, $perPage);
    }

    public function withPage(int $page): self
    {
        $this->assertValid($page, $this->perPage);

        return clone($this, ['page' => $page]);
    }

    private function assertValid(int $page, int $perPage): void
    {
        if ($page < 1) {
            throw new InvalidArgumentException('Page must be at least 1.');
        }

        if ($perPage < 1 || $perPage > 100) {
            throw new InvalidArgumentException('Per page must be between 1 and 100.');
        }
    }
}

$firstPage = new Pagination(1, 25);
$secondPage = $firstPage->withPage(2);

echo $firstPage->page . PHP_EOL;
echo $secondPage->page . PHP_EOL;

// Prints:
// 1
// 2

This pattern keeps invalid states out while still making the immutable update easy to read.

Choosing Direct Clone Or A Method

Direct clone($object, ['property' => $value]) is fine in small local code where the changed property is obvious and has no business rule.

Use a method such as withPage(), withEmail(), or markAsPaid() when the change has meaning, validation, or a name that helps the next developer understand the intention.

PHP example
<?php

declare(strict_types=1);

readonly class SearchFilters
{
    public function __construct(
        public ?string $query,
        public int $page,
    ) {
    }

    public function withQuery(?string $query): self
    {
        return clone($this, [
            'query' => $query === null ? null : trim($query),
            'page' => 1,
        ]);
    }
}

$filters = new SearchFilters(null, 4);
$changed = $filters->withQuery(' laptop ');

echo ($changed->query ?? 'none') . PHP_EOL;
echo $changed->page . PHP_EOL;

// Prints:
// laptop
// 1

The method captures a real application rule: changing the search query resets pagination to the first page.

What You Should Be Able To Do

After this lesson, you should be able to clone an object with changed properties, explain why the original object is unchanged, recognise shallow clone behaviour, and put validation around immutable updates.

For junior PHP work, this matters because value objects and DTO-like objects appear in modern codebases. The useful skill is not just the syntax; it is preserving object rules while making small changes readable.

Practice

Practice: Update Search Filters Immutably

Create a small immutable SearchFilters example using PHP 8.5 clone-with-properties syntax.

Task

Build:

  • a readonly SearchFilters class with query, page, and perPage properties
  • constructor validation for page >= 1
  • constructor validation for perPage between 1 and 100
  • a withQuery() method that trims the query and resets page to 1
  • a withPage() method that validates the new page

Use strict types. Show the original filters and changed filters so it is clear the original object did not change. Keep the expected output inside the PHP code block as printed lines or comments.

Afterward, add a short note explaining why the validation is inside named methods rather than scattered around the calling code.

Show solution

This solution keeps the update rules inside the value object so every caller gets the same behaviour.

PHP example
<?php

declare(strict_types=1);

readonly class SearchFilters
{
    public function __construct(
        public ?string $query,
        public int $page,
        public int $perPage,
    ) {
        $this->assertValid($page, $perPage);
    }

    public function withQuery(?string $query): self
    {
        $query = $query === null ? null : trim($query);

        return clone($this, [
            'query' => $query === '' ? null : $query,
            'page' => 1,
        ]);
    }

    public function withPage(int $page): self
    {
        $this->assertValid($page, $this->perPage);

        return clone($this, ['page' => $page]);
    }

    private function assertValid(int $page, int $perPage): void
    {
        if ($page < 1) {
            throw new InvalidArgumentException('Page must be at least 1.');
        }

        if ($perPage < 1 || $perPage > 100) {
            throw new InvalidArgumentException('Per page must be between 1 and 100.');
        }
    }
}

$filters = new SearchFilters(null, 3, 25);
$withQuery = $filters->withQuery(' laptop ');
$nextPage = $withQuery->withPage(2);

echo 'original: ' . ($filters->query ?? 'none') . ', page ' . $filters->page . PHP_EOL;
echo 'query: ' . ($withQuery->query ?? 'none') . ', page ' . $withQuery->page . PHP_EOL;
echo 'next: ' . ($nextPage->query ?? 'none') . ', page ' . $nextPage->page . PHP_EOL;

// Prints:
// original: none, page 3
// query: laptop, page 1
// next: laptop, page 2

The validation belongs in the named methods because the class owns its rules. Callers can ask for a changed filter object without remembering every page and query invariant themselves.