# RFC: Allow Reassignment of Promoted Readonly Properties in Constructor

- Status: Declined
- Authors: Nicolas Grekas nicolasgrekas@php.net
- Date: 2026-02-19
- Version: 0.2
- Original: https://wiki.php.net/rfc/promoted_readonly_constructor_reassign
- Page: https://externals.io/rfc/promoted_readonly_constructor_reassign
- More for agents: search with https://externals.io/search.md?q={query} · RFCs at https://externals.io/rfc.md · JSON API at https://externals.io/api · documentation at https://externals.io/llms.txt

## Votes

### Allow Reassignment of Promoted Readonly Properties in Constructor? (closed)

Yes: 11 · No: 10 · Abstain: 6

Voters: alcaeus (Yes), alexandredaubois (Yes), ayesh (Abstain), brzuchal (No), crell (Yes), cschneid (Abstain), daniels (Yes), derick (No), dharman (Yes), edorian (Yes), ericmann (No), galvao (Yes), ilutov (No), imsop (No), kalle (No), kocsismate (Yes), malferov (No), mbeccati (No), nicolasgrekas (Yes), nielsdos (No), ocramius (Yes), pmjones (Abstain), pollita (Abstain), seld (Yes), theodorejb (Abstain), timwolla (Abstain), weierophinney (No)

## Discussions on the mailing list

- [\[RFC\] Allow Reassignment of Promoted Readonly Properties in Constructor](https://externals.io/message/129851.md) — started on 2026-01-22, 39 messages
- [\[VOTE\] Allow Reassignment of Promoted Readonly Properties in Constructor](https://externals.io/message/130421.md) — started on 2026-03-23, 5 messages

---

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

```php
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:

```php
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:

```php
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:

```php
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

```php
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

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

#### Validation

```php
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

```php
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

```php
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:

```php
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);
    }
}
```

```php
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:

```php
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:

```php
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.

```php
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:

```php
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:

```php
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:

```php
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.

```php
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:

```php
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:

```php
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:

```php
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:**

```php
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:**

```php
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:**

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

**Validation with fallback:**

```php
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.

**Vote: Allow Reassignment of Promoted Readonly Properties in Constructor?** — Yes: 11 · No: 10 · Abstain: 6 (closed)

## Patches and Tests

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

## References

- [Mailing list discussion](https://news-web.php.net/php.internals/129851)
- [Readonly Properties 2.0 RFC](https://externals.io/rfc/readonly_properties_v2)
- [Constructor Property Promotion RFC](https://externals.io/rfc/constructor_promotion)
- [Readonly Amendments RFC](https://externals.io/rfc/readonly_amendments) (clone-related changes)
