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.

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

  1. The property must be declared using Constructor Property Promotion (have the ZEND_ACC_PROMOTED flag)
  2. The property must be readonly
  3. Reassignment is allowed while a constructor for the object is on the call stack (methods/closures called from it are allowed)
  4. Only one reassignment is permitted; subsequent assignments will throw an Error
  5. The reassignment must be on $this, not on a different object of the same class
  6. 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:

  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

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):

  1. Ensures IS_OBJ_CTOR_CALLED is not set
  2. 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:

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