rfc:promoted_readonly_constructor_reassign

PHP RFC: Allow Reassignment of Promoted Readonly Properties in Constructor

Introduction

Readonly properties and Constructor Property Promotion (CPP) don't mix well: any post-processing forces opting out of CPP.

This RFC allows exactly one reassignment of promoted readonly properties during construction, keeping CPP concise while still enabling validation/normalization/conditional initialization.

Problem Statement

Readonly properties (PHP 8.1) and CPP (PHP 8.0) are useful individually but awkward together.

Consider a class that needs to normalize input values:

class Point {
    public function __construct(
        public readonly float $x = 0.0,
        public readonly float $y = 0.0,
    ) {
        // ERROR: Cannot modify readonly property Point::$x
        $this->x = abs($x);
        $this->y = abs($y);
    }
}

Today, developers must choose between:

Option 1: Abandon CPP and write verbose code:

class Point {
    public readonly float $x;
    public readonly float $y;
 
    public function __construct(float $x = 0.0, float $y = 0.0) {
        $this->x = abs($x);
        $this->y = abs($y);
    }
}

Option 2: Skip post-processing:

class Point {
    public function __construct(
        public readonly float $x = 0.0,
        public readonly float $y = 0.0,
    ) {
        // No way to normalize $x and $y here
        // Caller must pass already-normalized values
    }
}

Option 3: Use property hooks (PHP 8.4+), which have different semantics and overhead:

class Point { 
    public function __construct( 
        public float $x = 0.0 { set => abs($value); }, 
        public float $y = 0.0 { set => abs($value); }, 
    ) {} 
} 

Property hooks normalize on every assignment (not just construction) and lose the write-once readonly guarantee without additional private(set) machinery — different semantics from what readonly provides.

None preserve CPP's conciseness while allowing post-initialization logic.

Proposal

Promoted readonly properties may be reassigned exactly once during the constructor call chain of the CPP-owning class.

Rules

  1. The property must be readonly
  2. Reassignment is allowed while a constructor of the CPP-owning class is on the call stack (methods/closures called from it are allowed)
  3. Only one reassignment is permitted; subsequent assignments will throw an Error
  4. The reassignment must be on $this, not on a different object of the same class
  5. Asymmetric visibility rules (private(set), protected(set)) still apply within the CPP-owning constructor chain

The CPP-owning class is the class whose constructor declares the property using Constructor Property Promotion (ZEND_ACC_PROMOTED). If a child class redeclares the property without CPP, CPP ownership remains with the ancestor class — meaning the ancestor constructor body can reassign once, but the child cannot open a new reassignment window. If a child redeclares the same property with CPP, CPP ownership moves to the child declaration: the child's implicit initialization runs first, and a later implicit CPP initialization from parent::__construct() fails because the property is already initialized.

Note: This RFC only affects properties initialized via Constructor Property Promotion, which are assigned before user code runs. Non-promoted readonly properties remain unchanged.

Examples

Basic Usage

class Point {
    public function __construct(
        public readonly float $x = 0.0,
        public readonly float $y = 0.0,
    ) {
        // Normalize values - allowed with this RFC
        $this->x = abs($x);
        $this->y = abs($y);
    }
}
 
$p = new Point(-5.0, -3.0);
echo $p->x; // 5.0
echo $p->y; // 3.0

Conditional Initialization

class Config {
    public function __construct(
        public readonly ?string $cacheDir = null,
    ) {
        $this->cacheDir ??= sys_get_temp_dir() . '/app_cache';
    }
}

Validation

class User {
    public function __construct(
        public readonly string $email,
    ) {
        if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
            throw new InvalidArgumentException('Invalid email');
        }
        $this->email = strtolower($email);  // Normalize
    }
}

Multiple Reassignments Fail

class Example {
    public function __construct(
        public readonly string $value = 'default',
    ) {
        $this->value = 'first';   // OK
        $this->value = 'second';  // Error: Cannot modify readonly property
    }
}

Non-Promoted Properties Unchanged

class Example {
    public readonly string $regular;
 
    public function __construct() {
        $this->regular = 'first';
        $this->regular = 'second';  // Error: Cannot modify readonly property
    }
}

Indirect Reassignment

Reassignment can happen from methods or closures called by the constructor as long as the constructor is on the stack:

class Example {
    public function __construct(
        public readonly string $value = 'default',
    ) {
        $this->initValue();  // OK - constructor is on the call stack
    }
 
    private function initValue(): void {
        $this->value = strtoupper($this->value);
    }
}
class Example {
    public function __construct(
        public readonly string $value = 'default',
    ) {
        $fn = function() {
            $this->value = 'from closure';  // OK - constructor is on the call stack
        };
        $fn();
    }
}

Direct __construct() Calls Cannot Bypass Readonly

Calling __construct() directly on an already-constructed object cannot bypass readonly protection. Reassignment is only allowed during the first constructor call:

class Foo {
    public function __construct(
        public readonly string $bar = 'default',
    ) {
        $this->bar = 'overwritten';  // OK during first construction
    }
}
 
$obj = new Foo();
echo $obj->bar; // "overwritten"
 
// Attempting to call __construct() directly fails:
try {
    $obj->__construct('attempt');  // Error: Cannot modify readonly property Foo::$bar
} catch (Error $e) {
    echo $e->getMessage(); // Cannot modify readonly property Foo::$bar
}

This is enforced by the existing readonly property state. After the first constructor call returns, the property is already initialized (not IS_PROP_UNINIT), so the constructor-specific reassignment window is closed and cannot be reopened by calling __construct() again on the same object. During first construction, reassignment is further restricted to the constructor call chain of the CPP-owning class.

Reflection: Objects Created Without Constructor

Objects created via ReflectionClass::newInstanceWithoutConstructor() have no constructor call yet, so an explicit __construct() later does allow CPP reassignment:

class Foo {
    public function __construct(
        public readonly string $bar = 'default',
    ) {
        $this->bar = 'overwritten';
    }
}
 
$ref = new ReflectionClass(Foo::class);
$obj = $ref->newInstanceWithoutConstructor();
// $obj->bar is uninitialized at this point
 
$obj->__construct('explicit');  // OK - first constructor call
echo $obj->bar; // "overwritten"
 
// Second call fails:
$obj->__construct('again');  // Error: Cannot modify readonly property Foo::$bar

Child Classes and CPP Ownership

A child class cannot reassign a parent's promoted readonly property from its own constructor body. The reassignment window belongs exclusively to the CPP-owning class.

class Parent_ {
    public function __construct(
        public readonly string $prop = 'parent default',
    ) {}
}
 
class Child extends Parent_ {
    public function __construct() {
        parent::__construct();
        $this->prop = 'child override';  // Error: cannot override parent CPP ownership
    }
}

“Set in child before parent::__construct()” also fails: $this->x = 'C'; succeeds (initializing the uninitialized slot), but then the implicit CPP assignment inside parent::__construct() fails because the property is already initialized:

class P {
    public function __construct(
        public readonly string $x = 'P',
    ) {}
}
 
class C extends P {
    public function __construct() {
        $this->x = 'C';
        parent::__construct();  // Error: Cannot modify readonly property P::$x
    }
}

Child redeclares without CPP — parent ownership is preserved:

If a child redeclares the property without CPP (e.g. to tighten visibility or add an attribute), CPP ownership remains with the ancestor. The parent constructor body can still perform its one reassignment:

class P {
    public function __construct(
        public readonly string $x = 'P1',
    ) {
        $this->x = 'P2';  // OK — P's CPP owns the reassignment window
    }
}
 
class C extends P {
    public readonly string $x;  // redeclare without CPP, e.g. to add #[SomeAttribute]
 
    public function __construct() {
        parent::__construct();
        // $this->x === 'P2' here; attempting to reassign would fail
    }
}

Child redeclares with its own CPP — parent's CPP initialization fails:

If both parent and child use CPP for the same property, the child's CPP runs first and owns the property. When parent::__construct() tries to initialize the same property, that fails because the property is no longer uninitialized:

class P {
    public function __construct(
        public readonly string $x = 'P',
    ) {}
}
 
class C extends P {
    public function __construct(
        public readonly string $x = 'C',
    ) {
        parent::__construct();  // Error: Cannot modify readonly property C::$x
    }
}

Visibility Rules

Asymmetric visibility modifiers (private(set), protected(set)) are respected. For promoted readonly parent properties, child reassignment already fails because the reassignment window is limited to the CPP-owning class constructor chain.

Within the CPP-owning class constructor chain, private(set) and protected(set) behave as usual.

class Parent_ {
    public function __construct(
        private(set) public readonly string $prop = 'default',
    ) {
        $this->init();
    }
 
    protected function init(): void {
        $this->prop = 'parent';  // OK: declaring class scope
    }
}
 
class Child extends Parent_ {
    protected function init(): void {
        $this->prop = 'child';  // Error! private(set) restricts to declaring class
    }
}

With protected(set), child scope is allowed when still inside the declaring constructor chain:

class Parent_ {
    public function __construct(
        protected(set) public readonly string $prop = 'default',
    ) {
        $this->init();
    }
 
    protected function init(): void {
        $this->prop = 'parent';
    }
}
 
class Child extends Parent_ {
    protected function init(): void {
        $this->prop = 'child';  // OK - protected(set) allows child classes
    }
}

Indirect Operations

Indirect operations like ++, --, +=, .= also work and count as the one reassignment:

class Counter {
    public function __construct(
        public readonly int $count = 0,
    ) {
        $this->count++;  // OK - counts as the one reassignment
    }
}

Rationale

Why Only Promoted Properties?

CPP performs an implicit assignment before user code runs, unlike regular properties where the first assignment is under user control.

For non-promoted readonly properties, the developer already has full control:

class Example {
    public readonly string $prop;
 
    public function __construct(string $value) {
        // Developer can do any processing before first assignment
        $processed = strtolower(trim($value));
        $this->prop = $processed;  // First and only assignment
    }
}

With CPP, this control is lost.

Relationship to __clone()

Readonly properties are already modifiable in __clone() via IS_PROP_REINITABLE.

This RFC keeps that model for clone, but constructor reassignment uses a slightly stricter variant: promoted readonly properties opened by CPP carry the normal reinitable bit plus a constructor-specific marker. Readonly writes then allow that window only while a matching promoted constructor for the same object is still active on the call stack.

This keeps clone semantics unchanged while preventing constructor-opened writability from leaking past constructor return or being confused with clone-opened writability.

Design Considerations

This RFC adds an option without mandating a style.

When Traditional Declaration is Preferred

Separating the constructor parameter from the property declaration can be clearer when:

Different types between parameter and property:

class Config {
    public readonly string $cacheDir;  // Non-nullable
 
    public function __construct(
        ?string $cacheDir = null,       // Nullable parameter
    ) {
        $this->cacheDir = $cacheDir ?? sys_get_temp_dir() . '/app_cache';
    }
}

Here, a nullable parameter becomes a non-nullable property after applying the default; CPP can't express this.

Detailed PHPDoc annotations:

class User {
    /** @var non-empty-string & lowercase-string */
    public readonly string $email;
 
    public function __construct(string $email) {
        if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
            throw new InvalidArgumentException('Invalid email');
        }
        $this->email = strtolower($email);
    }
}

Traditional declaration keeps detailed annotations clearly associated with the property.

Complex multi-step initialization:

Complex multi-step initialization can be clearer with traditional declaration.

When CPP + Reassignment Works Well

This RFC works well for:

Simple one-step transformations:

class Name {
    public function __construct(
        public readonly string $value,
    ) {
        $this->value = trim($value);
    }
}

Validation with fallback:

class Settings {
    public function __construct(
        public readonly string $timezone = 'UTC',
    ) {
        if (!in_array($this->timezone, timezone_identifiers_list(), true)) {
            $this->timezone = 'UTC';
        }
    }
}

Cases where parameter and property types are identical and no special annotations are needed.

The "Final Assignment" Question

A concern is that a CPP assignment may no longer be “final” without checking the constructor body.

However:

  1. The constraint is still rigid: at most one reassignment, only in the constructor
  2. This is opt-in: if “final at declaration” clarity is important, use traditional declaration

An earlier draft proposed restricting reassignment to the constructor body only. This was rejected to keep parity with __clone(), which allows modifications from called methods.

This RFC adds flexibility without removing options.

Backward Incompatible Changes

None. This RFC only enables previously invalid code.

Proposed PHP Version(s)

Next minor version (PHP 8.6 or later).

RFC Impact

To SAPIs

None.

To Existing Extensions

None. Extensions that create such objects get the same behavior.

To Ecosystem

Static analysis tools and IDEs will need updates to treat such reassignments as valid.

Proposed Voting Choices

Voting opens 2026-03-23 and closes 2026-04-07.

Requires 2/3 majority.

Allow Reassignment of Promoted Readonly Properties in Constructor?
Real name Yes No Abstain
alexandredaubois   
crell   
cschneid   
derick   
dharman   
galvao   
ilutov   
kalle   
kocsismate   
nielsdos   
timwolla   
Count: 6 3 2
This poll will close on 2026-04-07 16:00:00 UTC.

Patches and Tests

References

rfc/promoted_readonly_constructor_reassign.txt · Last modified: by nicolasgrekas