PHP 8.1 Readonly Properties Meet Constructor Promotion: What Changes for Value Objects
Combining PHP 8.1 readonly properties with constructor promotion gives you immutable, typed public fields in a single line — ideal for value objects, but watch out for serialization, inheritance, and deep‑immutability limits.
09 Dec 2025, 17:04 UTC

The problem: boilerplate vs. immutability
Value objects in PHP have traditionally required a private property, a typed constructor argument, a getter, and a docblock to make the intent clear. PHP 8.0 introduced constructor property promotion, which collapsed the declaration and assignment into the signature. PHP 8.1 added readonly properties, guaranteeing a single write after construction. The real productivity gain appears when you combine them: public function __construct(public readonly string $id) {}. This one line gives you a public, typed, immutable field without any extra methods.
How the two features interact
- Promotion (PHP 8.0) lets you declare visibility and type in the constructor parameters.
- Readonly (PHP 8.1) restricts the property to a single initialization, either at declaration or inside the constructor body.
- When used together, the promoted parameter becomes a
public readonlyproperty that is assigned exactly once — during object creation.
The combined syntax is now the idiomatic way to write DTOs, entities, and simple value objects in PHP 8.1+.
Worked example: a minimal identifier value object
<?php
final class UserId
{
public function __construct(
public readonly string $value
) {}
}
// Usage
$id = new UserId('550e8400-e29b-41d4-a716-446655440000');
var_dump($id);
// object(UserId)#1 (1) {
// ["value"]=> string(36) "550e8400-e29b-41d4-a716-446655440000"
// }
// Attempting a second write throws Error:
// $id->value = 'another'; // Fatal error: Cannot modify readonly property
Run the snippet on any PHP 8.1+ CLI to see the promoted readonly property in var_dump output and the fatal error on a second assignment.
Serialization considerations
json_encode, igbinary, and var_export treat a promoted readonly property like any public property — it appears in the payload. However, rebuilding the object from raw data cannot use direct property writes because the readonly guard blocks them after construction.
<?php
$json = json_encode($id); // {"value":"550e8400-e29b-41d4-a716-446655440000"}
$data = json_decode($json, true);
// Reconstruction options:
// 1. Call the constructor (preferred)
$restored = new UserId($data['value']);
// 2. Use reflection for libraries that hydrate via __unserialize
$ref = new ReflectionClass(UserId::class);
$obj = $ref->newInstanceWithoutConstructor();
$prop = $ref->getProperty('value');
$prop->setValue($obj, $data['value']); // works because object is uninitialized
If you control the codebase, prefer the constructor call. Reflection is a fallback for third‑party hydration libraries that still rely on ReflectionProperty::setValue().
Inheritance and visibility limits
- A child class cannot widen a parent
readonlyproperty to non‑readonly. - A promoted readonly property in a parent constructor is not automatically promoted in the child. The child must redeclare its own constructor signature if it needs additional parameters.
- Promoted properties keep the visibility you declare (
public,protected,private). You cannot make a promoted property private in the constructor and expose it via a getter without splitting the declaration.
These rules keep the type system predictable but mean you should design hierarchies with the final keyword or explicit redeclaration in mind.
Static analysis wins
Tools like PHPStan (level 5+), Psalm, and PHP_CodeSniffer treat promoted readonly properties as fully typed and immutable. They flag any write outside the constructor, catching accidental mutations at analysis time rather than runtime. This raises the confidence level of large codebases without extra annotations.
Trade‑offs and limitations
- Deep immutability is not guaranteed. Objects or arrays assigned to a readonly property can still be mutated internally. For full guarantees, upgrade to
readonly class(PHP 8.2) or use immutable collections. - Legacy hydration breaks. Libraries that write via
ReflectionProperty::setValue()after construction will throwError. Migration requires either library updates or custom__unserializeimplementations. - Version constraint. The combined pattern only works on PHP 8.1+. Projects stuck on 8.0 must keep separate property declarations.
Quick verification checklist
- Create a file
test.phpwith theUserIdclass above. - Run
php test.phpon a PHP 8.1+ binary — confirm thevar_dumpshows the property and the second assignment triggers a fatal error. - Execute
php -r "class A { public function __construct(public readonly string $x) {} } $a = new A('test'); echo json_encode($a);"to see the JSON payload. - Run
phpstan analyse test.php --level=5— no "Property is read-only" false positives should appear. - Extend
UserIdin a child class, instantiate it, and verify the parent property remains readonly and accessible.
All commands assume a standard CLI environment with the php binary in $PATH and permission to execute scripts. No elevated privileges are required.
Actionable next step
Adopt the public readonly promotion pattern for every new value object or DTO you write on PHP 8.1+. Run the verification checklist once per codebase to catch hydration or inheritance surprises early. When you move to PHP 8.2, consider readonly class for the remaining mutable‑reference edge cases.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.