Skip to content

Commit 06a4669

Browse files
Allow promoted readonly property to be reassigned once in constructor
1 parent 645e62b commit 06a4669

18 files changed

Lines changed: 623 additions & 133 deletions
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
--TEST--
2+
Promoted readonly property reassignment in constructor - basic
3+
--FILE--
4+
<?php
5+
6+
class Point {
7+
public function __construct(
8+
public readonly float $x = 0.0,
9+
public readonly float $y = 0.0,
10+
) {
11+
// Reassign promoted readonly properties - allowed once
12+
$this->x = abs($x);
13+
$this->y = abs($y);
14+
}
15+
}
16+
17+
$point = new Point();
18+
var_dump($point->x, $point->y);
19+
20+
$point2 = new Point(-5.0, -3.0);
21+
var_dump($point2->x, $point2->y);
22+
23+
?>
24+
--EXPECT--
25+
float(0)
26+
float(0)
27+
float(5)
28+
float(3)
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
--TEST--
2+
Promoted readonly property reassignment in constructor - child class cannot reassign parent's property
3+
--FILE--
4+
<?php
5+
6+
class Parent_ {
7+
public function __construct(
8+
public readonly string $prop = 'parent default',
9+
) {
10+
// Parent can reassign its own promoted property
11+
$this->prop = 'parent set';
12+
}
13+
}
14+
15+
class Child extends Parent_ {
16+
public function __construct() {
17+
parent::__construct();
18+
// Child cannot reassign parent's promoted property
19+
try {
20+
$this->prop = 'child override';
21+
} catch (Error $e) {
22+
echo $e->getMessage(), "\n";
23+
}
24+
}
25+
}
26+
27+
$parent = new Parent_();
28+
var_dump($parent->prop);
29+
30+
$child = new Child();
31+
var_dump($child->prop);
32+
33+
// Even when child has its own promoted property
34+
class Parent2 {
35+
public function __construct(
36+
public readonly string $parentProp = 'parent default',
37+
) {
38+
$this->parentProp = 'parent set';
39+
}
40+
}
41+
42+
class Child2 extends Parent2 {
43+
public function __construct(
44+
public readonly string $childProp = 'child default',
45+
) {
46+
parent::__construct();
47+
// Child can reassign its own promoted property
48+
$this->childProp = 'child set';
49+
// But cannot reassign parent's promoted property
50+
try {
51+
$this->parentProp = 'child override';
52+
} catch (Error $e) {
53+
echo $e->getMessage(), "\n";
54+
}
55+
}
56+
}
57+
58+
$child2 = new Child2();
59+
var_dump($child2->parentProp, $child2->childProp);
60+
61+
?>
62+
--EXPECT--
63+
string(10) "parent set"
64+
Cannot modify readonly property Parent_::$prop
65+
string(10) "parent set"
66+
Cannot modify readonly property Parent2::$parentProp
67+
string(10) "parent set"
68+
string(9) "child set"
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
--TEST--
2+
Promoted readonly property reassignment in constructor - conditional initialization
3+
--FILE--
4+
<?php
5+
6+
class Config {
7+
public function __construct(
8+
public readonly ?string $cacheDir = null,
9+
) {
10+
$this->cacheDir ??= sys_get_temp_dir() . '/app_cache';
11+
}
12+
}
13+
14+
$config1 = new Config();
15+
var_dump(str_contains($config1->cacheDir, 'app_cache'));
16+
17+
$config2 = new Config('/custom/cache');
18+
var_dump($config2->cacheDir);
19+
20+
?>
21+
--EXPECT--
22+
bool(true)
23+
string(13) "/custom/cache"
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
--TEST--
2+
Promoted readonly property reassignment in constructor - different object fails
3+
--FILE--
4+
<?php
5+
6+
class Point {
7+
public function __construct(
8+
public readonly float $x = 0.0,
9+
) {
10+
$this->x = abs($x);
11+
}
12+
13+
public static function createFrom(Point $other): Point {
14+
$new = new self();
15+
// Cannot modify another object's readonly property
16+
try {
17+
$other->x = 999.0;
18+
} catch (Error $e) {
19+
echo $e->getMessage(), "\n";
20+
}
21+
return $new;
22+
}
23+
}
24+
25+
$p1 = new Point(-5.0);
26+
var_dump($p1->x);
27+
28+
$p2 = Point::createFrom($p1);
29+
var_dump($p1->x); // Unchanged
30+
31+
// Also test: constructor cannot modify another instance of the same class
32+
class Counter {
33+
private static ?Counter $last = null;
34+
35+
public function __construct(
36+
public readonly int $value = 0,
37+
) {
38+
$this->value = $value + 1; // Allowed: own property
39+
40+
// Cannot modify previous instance
41+
if (self::$last !== null) {
42+
try {
43+
self::$last->value = 999;
44+
} catch (Error $e) {
45+
echo $e->getMessage(), "\n";
46+
}
47+
}
48+
self::$last = $this;
49+
}
50+
}
51+
52+
$c1 = new Counter(10);
53+
var_dump($c1->value);
54+
55+
$c2 = new Counter(20);
56+
var_dump($c1->value, $c2->value); // $c1 unchanged
57+
58+
?>
59+
--EXPECT--
60+
float(5)
61+
Cannot modify readonly property Point::$x
62+
float(5)
63+
int(11)
64+
Cannot modify readonly property Counter::$value
65+
int(11)
66+
int(21)
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
--TEST--
2+
Promoted readonly property reassignment in constructor - indirect reassignment not allowed
3+
--FILE--
4+
<?php
5+
6+
// Reassignment is NOT allowed in methods called by the constructor
7+
class CalledMethod {
8+
public function __construct(
9+
public readonly string $prop = 'default',
10+
) {
11+
try {
12+
$this->initProp();
13+
} catch (Error $e) {
14+
echo $e->getMessage(), "\n";
15+
}
16+
}
17+
18+
private function initProp(): void {
19+
$this->prop = 'from method';
20+
}
21+
}
22+
23+
$cm = new CalledMethod();
24+
var_dump($cm->prop);
25+
26+
// Reassignment is NOT allowed in closures called by the constructor
27+
class ClosureInConstructor {
28+
public function __construct(
29+
public readonly string $prop = 'default',
30+
) {
31+
$fn = function() {
32+
$this->prop = 'from closure';
33+
};
34+
try {
35+
$fn();
36+
} catch (Error $e) {
37+
echo $e->getMessage(), "\n";
38+
}
39+
}
40+
}
41+
42+
$cc = new ClosureInConstructor();
43+
var_dump($cc->prop);
44+
45+
?>
46+
--EXPECT--
47+
Cannot modify readonly property CalledMethod::$prop
48+
string(7) "default"
49+
Cannot modify readonly property ClosureInConstructor::$prop
50+
string(7) "default"
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
--TEST--
2+
Promoted readonly property reassignment in constructor - indirect operations (++, --, +=)
3+
--FILE--
4+
<?php
5+
6+
// Test that indirect operations also work for promoted readonly properties
7+
// Note: each operation (++, --, +=, etc.) consumes the one allowed reassignment
8+
9+
class Counter {
10+
public function __construct(
11+
public readonly int $count = 0,
12+
) {
13+
// Single increment works
14+
$this->count++;
15+
}
16+
}
17+
18+
$c = new Counter(5);
19+
var_dump($c->count);
20+
21+
// Multiple operations count as reassignments - second fails
22+
class MultiOp {
23+
public function __construct(
24+
public readonly int $value = 10,
25+
) {
26+
$this->value += 5; // First modification - allowed
27+
try {
28+
$this->value++; // Second modification - should fail
29+
} catch (Error $e) {
30+
echo $e->getMessage(), "\n";
31+
}
32+
}
33+
}
34+
35+
$m = new MultiOp();
36+
var_dump($m->value);
37+
38+
// Decrement works too
39+
class Decrement {
40+
public function __construct(
41+
public readonly int $value = 100,
42+
) {
43+
$this->value--;
44+
}
45+
}
46+
47+
$d = new Decrement();
48+
var_dump($d->value);
49+
50+
// Assignment operators work
51+
class AssignOps {
52+
public function __construct(
53+
public readonly string $text = 'hello',
54+
) {
55+
$this->text .= ' world';
56+
}
57+
}
58+
59+
$a = new AssignOps();
60+
var_dump($a->text);
61+
62+
?>
63+
--EXPECT--
64+
int(6)
65+
Cannot modify readonly property MultiOp::$value
66+
int(15)
67+
int(99)
68+
string(11) "hello world"
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
--TEST--
2+
Promoted readonly property reassignment in constructor - multiple reassignments fail
3+
--FILE--
4+
<?php
5+
6+
class Example {
7+
public function __construct(
8+
public readonly string $value = 'default',
9+
) {
10+
$this->value = 'first'; // OK - first reassignment
11+
try {
12+
$this->value = 'second'; // Error - second reassignment
13+
} catch (Error $e) {
14+
echo $e->getMessage(), "\n";
15+
}
16+
}
17+
}
18+
19+
$ex = new Example();
20+
var_dump($ex->value);
21+
22+
?>
23+
--EXPECT--
24+
Cannot modify readonly property Example::$value
25+
string(5) "first"
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
--TEST--
2+
Promoted readonly property reassignment in constructor - non-promoted properties unchanged
3+
--FILE--
4+
<?php
5+
6+
// Non-promoted readonly properties still cannot be reassigned
7+
class NonPromoted {
8+
public readonly string $prop;
9+
10+
public function __construct() {
11+
$this->prop = 'first';
12+
try {
13+
$this->prop = 'second'; // Should fail - not a promoted property
14+
} catch (Error $e) {
15+
echo $e->getMessage(), "\n";
16+
}
17+
}
18+
}
19+
20+
$np = new NonPromoted();
21+
var_dump($np->prop);
22+
23+
// Test mixed: promoted and non-promoted in same class
24+
class MixedProps {
25+
public readonly string $nonPromoted;
26+
27+
public function __construct(
28+
public readonly string $promoted = 'default',
29+
) {
30+
$this->nonPromoted = 'first';
31+
$this->promoted = 'reassigned'; // Allowed (promoted, first reassignment)
32+
try {
33+
$this->nonPromoted = 'second'; // Should fail (non-promoted)
34+
} catch (Error $e) {
35+
echo $e->getMessage(), "\n";
36+
}
37+
}
38+
}
39+
40+
$m = new MixedProps();
41+
var_dump($m->promoted, $m->nonPromoted);
42+
43+
?>
44+
--EXPECT--
45+
Cannot modify readonly property NonPromoted::$prop
46+
string(5) "first"
47+
Cannot modify readonly property MixedProps::$nonPromoted
48+
string(10) "reassigned"
49+
string(5) "first"

0 commit comments

Comments
 (0)