====== PHP RFC: Allow Reassignment of Promoted Readonly Properties in Constructor ====== * Version: 0.1 * Date: 2026-01-22 * 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. None preserve CPP's conciseness while allowing post-initialization logic. ===== Proposal ===== Promoted readonly properties may be reassigned **exactly once** during construction of the declaring class. ==== Rules ==== - The property must be declared using Constructor Property Promotion (have the ''ZEND_ACC_PROMOTED'' flag) - The property must be readonly - Reassignment is allowed while a constructor for the object 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 - a child class cannot reassign a ''private(set)'' property from the parent Note: This RFC only affects **promoted** readonly properties, which are initialized 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! Already constructed } 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()%%''. Calls to ''%%parent::__construct()%%'' during initial construction are allowed because the constructor has not yet completed. === 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 Can Reassign Parent Properties === A child class can reassign a parent's promoted readonly property if it hasn't already been reassigned and a constructor is on the call stack. This includes the normal case of calling ''%%parent::__construct()%%'' during initial construction; it does **not** allow reassignments from explicit ''%%__construct()%%'' calls made after construction has completed. class Parent_ { public function __construct( public readonly string $prop = 'parent default', ) { // Parent does NOT reassign here } } class Child extends Parent_ { public function __construct() { parent::__construct(); $this->prop = 'child override'; // OK - constructor is on the stack } } However, only one reassignment is allowed. If the parent already reassigned, the child cannot: class Parent2 { public function __construct( public readonly string $prop = 'parent default', ) { $this->prop = 'parent set'; // Uses the one reassignment } } class Child2 extends Parent2 { public function __construct() { parent::__construct(); $this->prop = 'child override'; // Error! Already reassigned by parent } } === Visibility Rules === Asymmetric visibility modifiers (''private(set)'', ''protected(set)'') are respected. A child class cannot reassign a parent's ''private(set)'' property: class Parent_ { public function __construct( private(set) public readonly string $prop = 'default', ) { // Parent doesn't use reassignment } } class Child extends Parent_ { public function __construct() { parent::__construct(); $this->prop = 'child'; // Error! private(set) restricts to declaring class } } With ''protected(set)'', child classes can reassign: class Parent_ { public function __construct( protected(set) public readonly string $prop = 'default', ) { // Parent doesn't use reassignment } } class Child extends Parent_ { public function __construct() { parent::__construct(); $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 adds ''IS_PROP_CPP_REINITABLE'' for one reassignment of promoted properties during construction, keeping the behaviors separate. This mirrors ''%%__clone()%%'': the property is modifiable while the special method is on the call stack. ==== 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 ==== ''IS_PROP_CPP_REINITABLE'' (in ''zend_types.h'') is set during the implicit CPP assignment and allows exactly one reassignment while a constructor is on the call stack. To prevent repeated ''%%__construct()%%'' calls from bypassing readonly, ''IS_OBJ_CTOR_CALLED'' is set when any constructor completes (in ''zend_leave_helper'') and never cleared. Reassignment is only allowed during the **first** constructor call, whether from ''new'' or an explicit call on a reflection-created object. The helper ''zend_is_in_original_construction(zobj)'': - Ensures ''IS_OBJ_CTOR_CALLED'' is **not** set - Walks the call stack to find a constructor frame for the object This allows methods/closures called from the constructor and child constructors to reassign (if unused). Readonly checks are modified in: * ''zend_std_write_property()'' in ''zend_object_handlers.c'' * ''zend_assign_to_typed_prop()'' in ''zend_execute.c'' * ''verify_readonly_and_avis()'' in ''ext/opcache/jit/zend_jit_helpers.c'' ==== Supported Operations ==== Direct assignments and indirect operations (++, --, +=, .=, etc.) are supported. Each counts as the one reassignment: class Example { public function __construct( public readonly int $count = 0, ) { $this->count++; // OK - counts as the one reassignment $this->count++; // Error - second reassignment not allowed } } ===== 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)