Table of Contents

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.

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 tracking whether a constructor has completed. After the first return, a flag prevents further reassignment, even via __construct(). During first construction, reassignment is further restricted to the constructor call chain of the class that declared the property.

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! Constructor already completed

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 reuses IS_PROP_REINITABLE for one reassignment of promoted properties during construction, following the same flag-based try/finally pattern as __clone().

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 YYYY-MM-DD and closes YYYY-MM-DD.

Requires 2/3 majority.

Allow Reassignment of Promoted Readonly Properties in Constructor?
Real name Yes No Abstain
Final result: 0 0 0
This poll has been closed.

Patches and Tests

Implementation: https://github.com/php/php-src/pull/20996

Implementation Notes

Core Implementation

This RFC reuses the existing IS_PROP_REINITABLE flag (zend_types.h). This follows the same try/finally pattern as __clone():

  1. Set on CPP initialization: When a readonly property is written for the first time inside a constructor (IS_PROP_UNINIT is set), IS_PROP_REINITABLE is set instead of clearing all property flags if either:
    • The property is promoted (ZEND_ACC_PROMOTED) and the executing constructor's scope matches prop_info->ce — the classical CPP ownership case; or
    • The property is not promoted on prop_info->ce (the child redeclared it without CPP) but the executing constructor's scope has a promoted readonly entry for the same property name (hash lookup in ctor_scope->properties_info) — the child-redeclaration case where CPP ownership stays with the ancestor.
  2. Clear on constructor exit: zend_leave_helper clears IS_PROP_REINITABLE from all promoted readonly properties of the constructor's scope when any constructor frame exits — including parent::__construct() calls (gated on ZEND_CALL_HAS_THIS | ZEND_ACC_CTOR, not just ZEND_CALL_RELEASE_THIS). Since child redeclarations share the same object slot, the cleanup happens correctly even in the non-promoted redeclaration case.

No object-level flag (IS_OBJ_CTOR_CALLED) is needed. A second __construct() call cannot bypass readonly because the property is already initialized (not IS_PROP_UNINIT), so IS_PROP_REINITABLE is never set again, and zend_assign_to_typed_prop() blocks the write.

Readonly checks are modified in:

References