0

PHP 8.1 — Readonly properties

Intermediate5 min read·php-09-011

Concept

Readonly properties (PHP 8.1) are properties that can only be written once — during initialization in __construct. After that, any write attempt throws \Error: Cannot modify readonly property. They enforce immutability at the language level for value objects, DTOs, and configuration objects.

Key rules:

  • Must be typed (a type declaration is required — readonly int $count not just readonly $count).
  • Cannot have a default value in PHP 8.1 (must be assigned in the constructor). PHP 8.2 relaxed this for class-level readonly classes.
  • After the constructor sets it, reading is always allowed; writing throws immediately.
  • clone with object modification: you cannot write to a readonly property even inside __clone() if the object was already initialized. The workaround is the "wither" pattern — return a new instance instead of mutating.
  • unset() is also forbidden on readonly properties after initialization.

readonly class (PHP 8.2): All promoted and non-static properties in the class are implicitly readonly. Shorthand for value objects where every property should be immutable. Extends/implements work normally; the readonly constraint propagates.

Wither pattern: For readonly objects that need "modified" versions, define wither methods: withX($newX): static that clone the object and return a new instance with one property changed. In PHP 8.4, property hooks provide a cleaner way to handle this.

Code Example

php
<?php
declare(strict_types=1);

// Readonly properties (PHP 8.1)
class UserId
{
    public function __construct(
        public readonly int $value,
    ) {}
}

$id = new UserId(42);
echo $id->value; // 42
// $id->value = 100; // Fatal Error: Cannot modify readonly property

// PHP 8.2 readonly class — all properties readonly
readonly class Money
{
    public function __construct(
        public float  $amount,
        public string $currency,
    ) {}

    // Wither pattern — returns new instance with one changed property
    public function withAmount(float $amount): static
    {
        return new static($amount, $this->currency);
    }

    public function plus(Money $other): static
    {
        if ($this->currency !== $other->currency) {
            throw new \InvalidArgumentException('Currency mismatch');
        }
        return new static($this->amount + $other->amount, $this->currency);
    }
}

$price    = new Money(9.99, 'EUR');
$taxed    = $price->withAmount(round($price->amount * 1.2, 2));
$total    = $price->plus(new Money(2.00, 'EUR'));

echo $price->amount;  // 9.99 — original unchanged
echo $taxed->amount;  // 11.99

// Readonly in DTO
readonly class CreateOrderCommand
{
    public function __construct(
        public int     $userId,
        public array   $items,
        public string  $currency,
        public ?string $couponCode = null,
    ) {}
}

$cmd = new CreateOrderCommand(
    userId:   1,
    items:    [['sku' => 'PHP-BOOK', 'qty' => 1]],
    currency: 'EUR',
);
// $cmd->userId = 2; // Fatal Error — readonly class means all props are readonly