This RFC seeks to allow default values for instance readonly properties. Historically, default values on readonly properties were not disallowed for technical reasons, but because they were considered “not particularly useful”. With interfaces properties, introduced in PHP 8.4, this changed. Allowing default values on readonly class properties enables creating a strict contract for implementation property values that will not change at runtime -- as in, constant-like behaviour with contract.
<?php interface Rule { public string $className { get; } } final class RuleA implements Rule { public readonly string $className = SomeParser::class; } final readonly class RuleB implements Rule { public string $className = SomeParser::class; } ?>
This RFC allows instance readonly properties to declare default values. The same applies to properties of readonly classes, because properties declared in a readonly class are implicitly readonly. No new syntax is introduced. This RFC only removes the current compile-time restriction that rejects an explicit default value on a readonly property. The default value follows the same rules as default values for non-readonly properties. Type checks, constant expression restrictions, inheritance checks, and trait composition are unchanged.
The reason for not allowing readonly properties was usefulness, not technical. Quote from the original “Readonly Properties” RFC:
As the default value counts as an initialising assignment, a readonly property with a default value is essentially the same as a constant, and thus not particularly useful. The notion could become more useful in the future if new expressions are allowed as property default values.
This means the original RFC actively highlighted to look out for future language additions as a reason that would make allowing readonly property default values worthwhile. The in PHP 8.4 landed interface properties are such a reason.
The readonly defaults are useful for classes that implement get-only interface properties. Such classes can provide contractable, fixed metadata or blueprint values without boilerplate constructor assignments or getter methods.
<?php interface IngestorBlueprint { public string $name { get; } public string $stub { get; } public string $path { get; } public array $steps { get; } } final readonly class SourceOneChangelogIngestor implements IngestorBlueprint { public string $name = 'Source One'; public string $stub = 'stubs/output.md'; public string $path = 'source-one/changelog/%s/%s'; public array $steps = [ ParserShapeA::class, HandleShapeA::class, ]; } ?>
This is intentionally not equivalent to a { get; set; } interface property. A readonly property, with or without a default value, satisfies a { get; } requirement but not a { get; set; } requirement.
<?php interface MutableName { public string $name { get; set; } } final readonly class Example implements MutableName { public string $name = 'Example'; // Invalid: a readonly property, even with default value, still only satisfies { get; }, but not { get; set; }. } ?>
A default value counts as the initialising assignment of the readonly property. This means the property is already initialised when the object is created, before the constructor body runs. Any later assignment, including from the constructor, is a modification and fails.
<?php final readonly class Rule { public string $className = SomeParser::class; public function __construct(string $className) { $this->className = $className; // Error: Cannot modify readonly property Rule::$className } } ?>
Readonly properties with defaults follow the existing inheritance rules for properties. A child class may inherit the default value. If a property is redeclared, the normal property compatibility checks apply.
Readonly properties without default values keep the existing behaviour: They may be unset before initialisation from the declaring scope. This can make the property visible to magic methods and enables the usual lazy initialisation pattern through __get(). Readonly properties with default values are already initialised when the object is created -- therefore, unset($this->prop) is treated as unsetting an initialised readonly property and throws an error.
<?php final class LazyValue { public readonly int $value = 1; public function __construct() { unset($this->value); // Error: Cannot unset readonly property LazyValue::$value } public function __get(string $name): mixed { echo "__get\n"; return 2; } } $object = new LazyValue(); var_dump($object->value); // int(1), __get() is not called ?>
Readonly properties with default values behave the same as other initialised readonly properties during cloning. If a class has a __clone() method, the cloned readonly property may be reinitialised once during that method.
<?php final class Counter { public readonly int $value = 1; public function __clone() { $this->value++; } } var_dump((clone new Counter())->value); // int(2) ?>
Serialisation follows existing readonly property behaviour. A readonly property with a default value is initialised, so it is included in normal object serialisation. Unserialisation continues to use the existing object hydration semantics and is not changed by this RFC.
Readonly properties may already be combined with asymmetric visibility. This RFC does not change those rules. A default value initialises the property immediately, so later writes fail as readonly modifications regardless of set visibility.
<?php final class Example { public private(set) readonly int $id = 1; } $example = new Example(); $example->id = 2; // Error: Cannot modify readonly property Example::$id ?>
Traits may declare readonly properties with default values. Existing trait property compatibility rules apply. If two imported trait properties define the same readonly property with the same default value, composition is allowed. If the default values differ, the composition is incompatible.
trait T1 { public readonly int $prop = 1; } trait T2 { public readonly int $prop = 2; } class Foo { use T1, T2; // Fatal error: T1 and T2 define the same property ($prop) in the composition of Foo. However, the definition differs... } ?>
Reflection reports readonly properties with default values in the same way as other properties with default values. ReflectionProperty::isReadOnly() returns true, ReflectionProperty::hasDefaultValue() returns true, and ReflectionProperty::getDefaultValue() returns the declared default value.
None. Readonly properties cannot declare hooks. This remains unchanged. A readonly property with a default value may satisfy a get-only interface property. It does not satisfy an interface property that requires set access.
This RFC does not change:
None.
PHP 8.6
The change only relaxes a compile-time validation rule for property declarations, the internal representation remains unchanged.
IDEs, language servers, static analysers, and other tools that currently report defaults on readonly properties as disallowed must be updated to allow them.
None.
None.
Primary Vote requiring a 2/3 majority to accept the RFC:
tbd
None.