PHP 8.1 — Readonly properties
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 $countnot justreadonly $count). - Cannot have a default value in PHP 8.1 (must be assigned in the constructor). PHP 8.2 relaxed this for class-level
readonlyclasses. - After the constructor sets it, reading is always allowed; writing throws immediately.
clonewith 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
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