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
<?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;
Output
(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 13

The 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
<?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";
Output
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
<?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);
Output
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
<?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";
Output
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
<?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";
Output
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
<?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";
Output
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
<?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");
Output
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 11

Try it yourself

Complete an immutable DateRange with a days() method and a withEnd() wither.

Try it yourself · main.php
Edit the code, copy it into main.php, then run it with php main.php (PHP 8.5). How to install PHP
Output
14 days, extended to 21 days