Immutable PHP: Using Readonly Properties and Classes for Value Objects
Learn how PHP 8.1+ readonly properties and PHP 8.2+ readonly classes enforce immutability in value objects and DTOs. See a concrete UserId example, trade-offs, and a quick adoption checklist.
20 Jul 2025, 17:27 UTC

Problem: Mutable IDs leak through a codebase
In PHP domain models, IDs, addresses and configuration DTOs are created once and then passed around. When they are mutable, accidental reassignment can corrupt business state and make reasoning harder. Defensive copying and extra tests are common workarounds.
Thesis: Readonly makes immutability a language guarantee
PHP 8.1 introduced readonly properties and PHP 8.2 added readonly classes. Together they let you declare objects that cannot change after construction, removing the need for defensive copies and making intent explicit.
Readonly properties in PHP 8.1
A readonly property can be written only once, typically in the constructor. A later assignment throws Error.
class Point
{
public readonly int $x;
public readonly int $y;
public function __construct(int $x, int $y)
{
$this->x = $x;
$this->y = $y;
}
}
Check the runtime:
php -v
php -r "$p = new Point(1,2); $p->x = 5;"
The second command produces an Error: Cannot modify readonly property Point::$x. Run this on PHP 8.1+ to confirm.
Readonly classes in PHP 8.2
A readonly class makes every property readonly implicitly.
readonly class User
{
public string $name;
public int $age;
public function __construct(string $name, int $age)
{
$this->name = $name;
$this->age = $age;
}
}
Verify with reflection:
$rc = new ReflectionClass(User::class);
foreach ($rc->getProperties() as $prop) {
echo $prop->isReadOnly() ? 'readonly ' : 'mutable ';
echo $prop->getName() . PHP_EOL;
}
Constructor property promotion with readonly
PHP 8.0 constructor property promotion combined with readonly gives compact immutable DTOs.
class UserId
{
public function __construct(
public readonly string $value
) {}
}
Worked example: Immutable UserId value object
A UserId should never change after creation.
class UserId
{
public function __construct(
public readonly string $value
) {
if (!preg_match('/^[a-f0-9]{32}$/', $value)) {
throw new InvalidArgumentException('Invalid UUID');
}
}
}
Usage:
$id = new UserId('123e4567e89b12d3a456426655440000');
Unit test the contract:
$this->expectException(Error::class);
$id->value = 'new';
The exception confirms the property is immutable.
Trade-offs and limitations
Readonly is shallow. It prevents reassignment of the property itself, not mutation of a mutable object it references. Arrays or objects held by a readonly property can still be changed internally.
All readonly properties must be initialized before first use. Cloning or custom unserialization requires special handling, otherwise assignment fails.
Version sensitivity applies: readonly properties need PHP 8.1+, readonly classes need PHP 8.2+.
Actionable adoption
- Run php -v to confirm 8.1+ for properties, 8.2+ for readonly classes.
- Identify identity or configuration objects that must not change after creation.
- Refactor to readonly properties or a readonly class, using constructor property promotion to reduce boilerplate.
- Add a test that attempts post-construction mutation and asserts Error is thrown.
- Document the immutability contract and avoid holding mutable nested state in readonly properties.
Using readonly where appropriate makes invalid state changes impossible at runtime and clarifies domain intent.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.