Readonly Property Defaults in PHP 8.6: A Four-Year-Old Restriction Finally Lifts
PHP 8.6 lets readonly properties declare default values. Here is what changed, why interface properties made it worthwhile, and the edge cases to watch.
If you have ever written this and watched PHP reject it, you are not alone:
final class CreateBooksTable
{
public readonly string $name = '2026_01_01_create_books_table';
}
Since readonly properties landed in PHP 8.1, that line has been a fatal error: Readonly property CreateBooksTable::$name cannot have default value. As of PHP 8.6, it compiles. The Readonly Property Defaults RFC by Nick Sdot passed on August 7, 2026 with 24 yes votes, zero no votes, and 5 abstentions, and was merged into php-src a few days later. PHP 8.6 is scheduled for November 19, 2026.
This is a small change. The RFC does not introduce syntax, does not change the engine’s internal representation, and has no backward incompatible changes. All it does is remove one compile-time check. But it closes a gap that has been quietly annoying anyone building plugin systems, migration runners, or rule registries.
Why the restriction existed in the first place
The interesting part is that this was never a technical limitation. The original Readonly Properties RFC from 2021 says so directly:
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.
That reasoning held up for three years. If you wanted a fixed value on a class, you used a class constant. A readonly property with a default was just a constant wearing a costume.
Then PHP 8.4 shipped interface properties, and the calculus changed. You can now write a contract that requires an instance property:
interface Rule
{
public string $className { get; }
}
Class constants cannot satisfy that contract. Neither can a method. The interface demands a property, and until 8.6 the only way to provide a fixed one was boilerplate:
final class RuleA implements Rule
{
public readonly string $className;
public function __construct()
{
$this->className = SomeParser::class;
}
}
A constructor that exists purely to hardcode one string is not a design decision, it is a tax. In 8.6:
final class RuleA implements Rule
{
public readonly string $className = SomeParser::class;
}
// Or, with a readonly class, where properties are implicitly readonly:
final readonly class RuleB implements Rule
{
public string $className = SomeParser::class;
}
The RFC’s own motivating example is a blueprint-style ingestor, which is the shape most of us will actually hit:
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,
];
}
Fixed metadata, enforced by a contract, with no constructor and no getters. If you maintain anything that discovers classes and reads configuration off them, whether that is a Symfony compiler pass, a Laravel service provider scanning a directory, or the tooling we use at php[architect] to build issue manifests, this is the pattern you have been writing longhand.
The rule that matters most
A default value counts as the initialising assignment. The property is set before the constructor body runs. Every write after that, including from inside the constructor, is a modification and fails:
final readonly class Rule
{
public string $className = SomeParser::class;
public function __construct(string $className)
{
$this->className = $className;
// Error: Cannot modify readonly property Rule::$className
}
}
If you want a value that is sometimes injected and sometimes falls back, a default on a readonly property is the wrong tool. Use a promoted constructor parameter with a default instead. The RFC explicitly lists promotion as a non-goal: a default on a promoted parameter is still a parameter default, not a property default, and that behavior is unchanged.
Edge cases worth knowing
Interfaces still distinguish get from set. A readonly property with a default satisfies { get; }. It does not satisfy { get; set; }, and the compiler will tell you so. That is intentional.
Inheritance works normally. A child can redeclare and override the default, subject to the usual property compatibility checks:
abstract class ParentRule
{
public readonly int $priority = 1;
}
final class ChildRule extends ParentRule
{
public readonly int $priority = 2;
}
var_dump(new ParentRule()->priority); // int(1)
var_dump(new ChildRule()->priority); // int(2)
unset() is off the table. An uninitialised readonly property can be unset from the declaring scope, which is how the lazy-initialisation-via-__get() trick works. A readonly property with a default is already initialised, so unset($this->prop) throws, and __get() never fires.
Cloning still gets one write. Inside __clone(), and with clone-with, you can reinitialise the property exactly once, same as any other initialised readonly property:
final class Counter
{
public readonly int $value = 1;
public function withValue(int $value): self
{
return clone($this, ['value' => $value]);
}
}
$counter = new Counter();
var_dump($counter->value); // int(1)
var_dump($counter->withValue(2)->value); // int(2)
Serialisation includes it. Because the property is initialised, it lands in the serialised payload and can be restored from it, either natively or through __unserialize(). After hydration the property is readonly again. Worth thinking about if you serialise objects into a cache and later change the default: the old value comes back, not the new one.
Asymmetric visibility does not create a loophole. public public(set) readonly int $id = 1; is legal, and writing to $id still fails, because the default already initialised it.
Traits compose only on exact matches. Two traits declaring the same readonly property with the same default compose fine. Different defaults are a fatal error, consistent with existing trait property rules.
Reflection behaves. isReadOnly() returns true, hasDefaultValue() returns true, getDefaultValue() returns the value. Nothing special to handle.
What you actually need to do
Nothing, until November. There is no BC break and no migration. The one item on the ecosystem’s plate, called out explicitly in the RFC’s impact section, is tooling: IDEs, language servers, and static analysers that currently report a default on a readonly property as disallowed need updating. If you try this against a strict analysis config on a PHP 8.6 alpha before your tools catch up, expect false positives from the analyser rather than from the engine.
The broader point is worth sitting with. This RFC exists because the 2021 authors wrote down why they said no, and left a marker for what would change their minds. Interface properties arrived, the marker got hit, and someone did the work. That is a healthier way to run a language than reversing a decision nobody documented.
Sources
- PHP RFC: Readonly Property Defaults — wiki.php.net
- Readonly Property Defaults vote results — PHP Foundation Discourse, August 7, 2026
- Readonly Property Defaults RFC status — PHP.Watch
- PHP 8.6 RFC List — PHP.Watch
- What’s new in PHP 8.6 — Brent Roose, stitcher.io, July 29, 2026
- PHP RFC: Readonly Properties 2.0 — wiki.php.net
- Implementation commit — php/php-src