advanced php language
PHP 8.5 Clone With Changed Properties
<?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
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
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
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
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
readonlySearchFiltersclass withquery,page, andperPageproperties - constructor validation for
page >= 1 - constructor validation for
perPagebetween 1 and 100 - a
withQuery()method that trims the query and resetspageto1 - 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
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.