====== PHP RFC: Allow Reassignment of Promoted Readonly Properties in Constructor ====== * Version: 0.2 * Date: 2026-02-19 * Author: Nicolas Grekas * Status: Under Discussion * First Published at: https://wiki.php.net/rfc/promoted_readonly_constructor_reassign ===== 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 ==== - The property must be readonly - Reassignment is allowed while a constructor of the **CPP-owning class** is on the call stack (methods/closures called from it are allowed) - Only one reassignment is permitted; subsequent assignments will throw an Error - The reassignment must be on ''$this'', not on a different object of the same class - 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: - The constraint is still rigid: **at most one** reassignment, only in the constructor - 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. * Yes * No * Abstain ===== 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()%%'': - **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. - **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: * ''zend_std_write_property()'' in ''zend_object_handlers.c'' — CPP implicit assignment and first-init path * ''zend_assign_to_typed_prop()'' in ''zend_execute.c'' — VM opcode assignment path * ''verify_readonly_and_avis()'' in ''ext/opcache/jit/zend_jit_helpers.c'' — JIT path ===== References ===== * [[https://news-web.php.net/php.internals/129851|Mailing list discussion]] * [[https://wiki.php.net/rfc/readonly_properties_v2|Readonly Properties 2.0 RFC]] * [[https://wiki.php.net/rfc/constructor_promotion|Constructor Property Promotion RFC]] * [[https://wiki.php.net/rfc/readonly_amendments|Readonly Amendments RFC]] (clone-related changes)