Readonly and Immutability
An immutable object never changes after it's created. That sounds limiting, but it removes a whole class of bugs: nothing can modify an object behind your back, so it's always in the state it was built in. PHP supports immutability with readonly properties and classes, and PHP 8.5's clone with new values makes "change by copying" easy.
readonly properties
A readonly property can be assigned once, from inside the class (normally in the constructor). After that, any attempt to change it is an error:
<?php final class Point { public function __construct( public readonly float $x, public readonly float $y, ) {} } $p = new Point(3, 4); echo "($p->x, $p->y)\n"; $p->x = 10;
(3, 4)
Fatal error: Uncaught Error: Cannot modify readonly property Point::$x in main.php:13
Stack trace:
#0 {main}
thrown in main.php on line 13The properties can be public, because no one can change them anyway — no getters needed. A readonly property must have a type.
readonly classes
Marking the whole class readonly (PHP 8.2) makes every property readonly and forbids dynamic properties:
<?php final readonly class Money { public function __construct( public int $cents, public string $currency, ) {} public function add(Money $other): static { if ($other->currency !== $this->currency) { throw new InvalidArgumentException("Currency mismatch"); } return new static($this->cents + $other->cents, $this->currency); } public function format(): string { return sprintf("%.2f %s", $this->cents / 100, $this->currency); } } $price = new Money(1999, "EUR"); $total = $price->add(new Money(500, "EUR")); echo $price->format(), "\n"; echo $total->format(), "\n";
19.99 EUR 24.99 EUR
Notice the style: instead of changing $price, add() returns a new Money. The original is untouched, so any other code holding $price isn't surprised.
"Withers": changing a copy
To change one property of an immutable object, return a modified copy from a method conventionally named with…(). PHP 8.5 lets clone take an array of new property values, which makes these methods one-liners:
<?php final readonly class Settings { public function __construct( public string $theme = 'light', public int $fontSize = 14, public string $language = 'en', ) {} public function withTheme(string $theme): static { return clone($this, ['theme' => $theme]); } public function withFontSize(int $size): static { return clone($this, ['fontSize' => max(8, min(32, $size))]); } } $defaults = new Settings(); $mine = $defaults->withTheme('dark')->withFontSize(18); printf("defaults: %s %d %s\n", $defaults->theme, $defaults->fontSize, $defaults->language); printf("mine: %s %d %s\n", $mine->theme, $mine->fontSize, $mine->language);
defaults: light 14 en mine: dark 18 en
Readonly properties are writable only from inside their class, so clone with new values must be called from a method of the class — exactly where with…() methods live. (Before PHP 8.5, withers created a new object by calling the constructor with all the old values plus the changed one.)
Why immutability helps
<?php class MutableDate { public function __construct(public string $date) {} } function nextWeek(MutableDate $d): MutableDate { $d->date = date('Y-m-d', strtotime($d->date . ' +7 days')); // modifies the caller's object! return $d; } $invoiceDate = new MutableDate('2026-10-01'); $dueDate = nextWeek($invoiceDate); echo "invoice {$invoiceDate->date}, due {$dueDate->date}\n";
invoice 2026-10-08, due 2026-10-08
The invoice date was silently changed, because objects are passed by handle. PHP's own date classes show both designs: DateTime is mutable and has exactly this problem, while DateTimeImmutable returns new objects (lesson 56):
<?php $invoice = new DateTimeImmutable('2026-10-01'); $due = $invoice->modify('+7 days'); echo "invoice {$invoice->format('Y-m-d')}, due {$due->format('Y-m-d')}\n";
invoice 2026-10-01, due 2026-10-08
Readonly is shallow
readonly stops the property being reassigned, but if it holds an object, that object can still change internally. For full immutability, the objects inside should be immutable too:
<?php final class Order { public function __construct(public readonly ArrayObject $items) {} } $order = new Order(new ArrayObject(['pen'])); $order->items[] = 'surprise'; // allowed: the ArrayObject itself is mutable echo count($order->items), "\n";
2
Arrays don't have this problem: a readonly array property can't be changed at all, because arrays are values in PHP.
Value objects
Small immutable classes that represent a value — an email address, an amount of money, a date range, coordinates — are called value objects. They validate in the constructor, so an invalid one can't exist, and they make function signatures self-documenting: send(EmailAddress $to) is clearer and safer than send(string $to).
<?php final readonly class EmailAddress { public string $value; public function __construct(string $email) { $email = strtolower(trim($email)); if (!filter_var($email, FILTER_VALIDATE_EMAIL)) { throw new InvalidArgumentException("Invalid email address: $email"); } $this->value = $email; } public function domain(): string { return substr($this->value, strpos($this->value, '@') + 1); } } $e = new EmailAddress(" Grace@Example.ORG "); echo $e->value, " at ", $e->domain(), "\n"; new EmailAddress("nope");
grace@example.org at example.org
Fatal error: Uncaught InvalidArgumentException: Invalid email address: nope in main.php:11
Stack trace:
#0 main.php(24): EmailAddress->__construct('nope')
#1 {main}
thrown in main.php on line 11Try it yourself
Complete an immutable DateRange with a days() method and a withEnd() wither.
14 days, extended to 21 days