Skip to content
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.

Commit 12b986a

Browse files
committedJun 7, 2025·
feat: allow hooks for backed readonly properties
1 parent 690cde6 commit 12b986a

21 files changed

+587
-40
lines changed
 

‎NEWS

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ PHP NEWS
5353
evaluation) and GH-18464 (Recursion protection for deprecation constants not
5454
released on bailout). (DanielEScherzer and ilutov)
5555
. Fixed AST printing for immediately invoked Closure. (Dmitrii Derepko)
56+
. Property hooks are now allowed on backed readonly properties. (Crell, NickSdot and iluuu1994)
5657

5758
- Curl:
5859
. Added curl_multi_get_handles(). (timwolla)

‎UPGRADING

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,8 @@ PHP 8.5 UPGRADE NOTES
144144
RFC: https://wiki.php.net/rfc/attributes-on-constants
145145
. The #[\Deprecated] attribute can now be used on constants.
146146
RFC: https://wiki.php.net/rfc/attributes-on-constants
147+
. Property hooks are now allowed on backed readonly properties.
148+
RFC: https://wiki.php.net/rfc/readonly_hooks
147149

148150
- Curl:
149151
. Added support for share handles that are persisted across multiple PHP

‎Zend/tests/property_hooks/gh15419_1.phpt

Lines changed: 0 additions & 12 deletions
This file was deleted.

‎Zend/tests/property_hooks/gh15419_2.phpt

Lines changed: 0 additions & 14 deletions
This file was deleted.

‎Zend/tests/property_hooks/readonly.phpt

Lines changed: 0 additions & 12 deletions
This file was deleted.
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
--TEST--
2+
Backed property in readonly class may have hooks
3+
--FILE--
4+
<?php
5+
6+
// readonly class
7+
readonly class Test {
8+
public int $prop {
9+
get => $this->prop;
10+
set => $value;
11+
}
12+
13+
public function __construct(int $v) {
14+
$this->prop = $v;
15+
}
16+
17+
public function set($v)
18+
{
19+
$this->prop = $v;
20+
}
21+
}
22+
23+
$t = new Test(42);
24+
var_dump($t->prop);
25+
try {
26+
$t->set(43);
27+
} catch (Error $e) {
28+
echo $e->getMessage(), "\n";
29+
}
30+
try {
31+
$t->prop = 43;
32+
} catch (Error $e) {
33+
echo $e->getMessage(), "\n";
34+
}
35+
var_dump($t->prop);
36+
?>
37+
--EXPECT--
38+
int(42)
39+
Cannot modify readonly property Test::$prop
40+
Cannot modify protected(set) readonly property Test::$prop from global scope
41+
int(42)
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
--TEST--
2+
Non-readonly class cannot extend readonly class
3+
--FILE--
4+
<?php
5+
6+
readonly class ParentClass {
7+
public int $prop;
8+
}
9+
10+
class Test extends ParentClass {
11+
public function __construct(
12+
public int $prop {
13+
get => $this->prop;
14+
set => $value;
15+
}
16+
) {}
17+
}
18+
19+
?>
20+
--EXPECTF--
21+
Fatal error: Non-readonly class Test cannot extend readonly class ParentClass in %s on line %d
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
--TEST--
2+
Readonly class cannot extend non-readonly class
3+
--FILE--
4+
<?php
5+
6+
class ParentClass {
7+
public int $prop;
8+
}
9+
10+
readonly class Test extends ParentClass {
11+
public function __construct(
12+
public int $prop {
13+
get => $this->prop;
14+
set => $value;
15+
}
16+
) {}
17+
}
18+
19+
?>
20+
--EXPECTF--
21+
Fatal error: Readonly class Test cannot extend non-readonly class ParentClass in %s on line %d
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
--TEST--
2+
Backed promoted property in readonly class may have hooks
3+
--FILE--
4+
<?php
5+
6+
// readonly class, promoted
7+
readonly class Test {
8+
public function __construct(
9+
public int $prop {
10+
get => $this->prop;
11+
set => $value;
12+
}
13+
) {}
14+
15+
public function set($v)
16+
{
17+
$this->prop = $v;
18+
}
19+
}
20+
21+
$t = new Test(42);
22+
var_dump($t->prop);
23+
try {
24+
$t->set(43);
25+
} catch (Error $e) {
26+
echo $e->getMessage(), "\n";
27+
}
28+
try {
29+
$t->prop = 43;
30+
} catch (Error $e) {
31+
echo $e->getMessage(), "\n";
32+
}
33+
var_dump($t->prop);
34+
?>
35+
--EXPECT--
36+
int(42)
37+
Cannot modify readonly property Test::$prop
38+
Cannot modify protected(set) readonly property Test::$prop from global scope
39+
int(42)
40+
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
--TEST--
2+
Virtual promoted property in readonly class cannot have hooks
3+
--FILE--
4+
<?php
5+
6+
readonly class Test {
7+
public function __construct(
8+
public int $prop {
9+
get => 42;
10+
}
11+
) {}
12+
}
13+
14+
?>
15+
--EXPECTF--
16+
Fatal error: Hooked virtual properties cannot be readonly in %s on line %d
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
--TEST--
2+
Readonly classes can be constructed via reflection by ORM
3+
--FILE--
4+
<?php
5+
6+
interface DbConnection {
7+
public function loadCategory(string $id): Category;
8+
}
9+
10+
class Category {
11+
public function __construct(public string $name) {}
12+
}
13+
14+
class MockDbConnection implements DbConnection {
15+
public function loadCategory(string $id): Category {
16+
echo "hit database\n";
17+
return new Category("Category {$id}");
18+
}
19+
}
20+
21+
readonly class Product
22+
{
23+
public function __construct(
24+
public string $name,
25+
public float $price,
26+
public Category $category,
27+
) {}
28+
}
29+
30+
readonly class LazyProduct extends Product
31+
{
32+
private DbConnection $dbApi;
33+
34+
private string $categoryId;
35+
36+
public Category $category {
37+
get {
38+
return $this->category ??= $this->dbApi->loadCategory($this->categoryId);
39+
}
40+
}
41+
}
42+
43+
$reflect = new ReflectionClass(LazyProduct::class);
44+
$product = $reflect->newInstanceWithoutConstructor();
45+
46+
$nameProperty = $reflect->getProperty('name');
47+
$nameProperty->setAccessible(true);
48+
$nameProperty->setValue($product, 'Iced Chocolate');
49+
50+
$priceProperty = $reflect->getProperty('price');
51+
$priceProperty->setAccessible(true);
52+
$priceProperty->setValue($product, 1.99);
53+
54+
$db = $reflect->getProperty('dbApi');
55+
$db->setAccessible(true);
56+
$db->setValue($product, new MockDbConnection());
57+
58+
$categoryId = $reflect->getProperty('categoryId');
59+
$categoryId->setAccessible(true);
60+
$categoryId->setValue($product, '42');
61+
62+
// lazy loading, hit db
63+
$category1 = $product->category;
64+
echo $category1->name . "\n";
65+
66+
// cached category returned
67+
$category2 = $product->category;
68+
echo $category2->name . "\n";
69+
70+
// same category instance returned
71+
var_dump($category1 === $category2);
72+
73+
// can't be wrong, huh?
74+
var_dump($product);
75+
76+
// cannot set twice
77+
try {
78+
$categoryId->setValue($product, '420');
79+
} catch (Error $e) {
80+
echo $e->getMessage(), "\n";
81+
}
82+
83+
?>
84+
--EXPECT--
85+
hit database
86+
Category 42
87+
Category 42
88+
bool(true)
89+
object(LazyProduct)#2 (5) {
90+
["name"]=>
91+
string(14) "Iced Chocolate"
92+
["price"]=>
93+
float(1.99)
94+
["category"]=>
95+
object(Category)#8 (1) {
96+
["name"]=>
97+
string(11) "Category 42"
98+
}
99+
["dbApi":"LazyProduct":private]=>
100+
object(MockDbConnection)#6 (0) {
101+
}
102+
["categoryId":"LazyProduct":private]=>
103+
string(2) "42"
104+
}
105+
Cannot modify readonly property LazyProduct::$categoryId
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
--TEST--
2+
Backed readonly property may have hooks
3+
--FILE--
4+
<?php
5+
6+
// readonly property
7+
class Test {
8+
public readonly int $prop {
9+
get => $this->prop;
10+
set => $value;
11+
}
12+
13+
public function __construct(int $v) {
14+
$this->prop = $v;
15+
}
16+
17+
public function set($v)
18+
{
19+
$this->prop = $v;
20+
}
21+
}
22+
23+
$t = new Test(42);
24+
var_dump($t->prop);
25+
try {
26+
$t->set(43);
27+
} catch (Error $e) {
28+
echo $e->getMessage(), "\n";
29+
}
30+
try {
31+
$t->prop = 43;
32+
} catch (Error $e) {
33+
echo $e->getMessage(), "\n";
34+
}
35+
var_dump($t->prop);
36+
37+
// class readonly
38+
final readonly class Foo
39+
{
40+
public function __construct(
41+
public array $values {
42+
set(array $value) => array_map(strtoupper(...), $value);
43+
},
44+
) {}
45+
}
46+
47+
// property readonly
48+
final class Foo2
49+
{
50+
public function __construct(
51+
public readonly array $values {
52+
set(array $value) => array_map(strtoupper(...), $value);
53+
},
54+
) {}
55+
}
56+
57+
// redundant readonly
58+
final readonly class Foo3
59+
{
60+
public function __construct(
61+
public readonly array $values {
62+
set(array $value) => array_map(strtoupper(...), $value);
63+
get => $this->makeNicer($this->values);
64+
},
65+
) {}
66+
67+
public function makeNicer(array $entries): array
68+
{
69+
return array_map(
70+
fn($i, $entry) => $entry . strtoupper(['', 'r', 'st'][$i]), array_keys($entries),
71+
$entries
72+
);
73+
}
74+
}
75+
76+
\var_dump(new Foo(['yo,', 'you', 'can'])->values);
77+
\var_dump(new Foo2(['just', 'do', 'things'])->values);
78+
\var_dump(new Foo3(['nice', 'nice', 'nice'])->values);
79+
?>
80+
--EXPECT--
81+
int(42)
82+
Cannot modify readonly property Test::$prop
83+
Cannot modify protected(set) readonly property Test::$prop from global scope
84+
int(42)
85+
array(3) {
86+
[0]=>
87+
string(3) "YO,"
88+
[1]=>
89+
string(3) "YOU"
90+
[2]=>
91+
string(3) "CAN"
92+
}
93+
array(3) {
94+
[0]=>
95+
string(4) "JUST"
96+
[1]=>
97+
string(2) "DO"
98+
[2]=>
99+
string(6) "THINGS"
100+
}
101+
array(3) {
102+
[0]=>
103+
string(4) "NICE"
104+
[1]=>
105+
string(5) "NICER"
106+
[2]=>
107+
string(6) "NICEST"
108+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
--TEST--
2+
Backed property cannot redeclare readonly as non-readonly property
3+
--FILE--
4+
<?php
5+
6+
class ParentClass {
7+
public readonly int $prop;
8+
}
9+
10+
class Test extends ParentClass {
11+
public function __construct(
12+
public int $prop {
13+
get => $this->prop;
14+
set => $value;
15+
}
16+
) {}
17+
}
18+
19+
?>
20+
--EXPECTF--
21+
Fatal error: Cannot redeclare readonly property ParentClass::$prop as non-readonly Test::$prop %s on line %d
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
--TEST--
2+
Backed property cannot redeclare non-readonly as readonly property
3+
--FILE--
4+
<?php
5+
6+
class ParentClass {
7+
public int $prop;
8+
}
9+
10+
class Test extends ParentClass {
11+
public function __construct(
12+
public readonly int $prop {
13+
get => $this->prop;
14+
set => $value;
15+
}
16+
) {}
17+
}
18+
19+
?>
20+
--EXPECTF--
21+
Fatal error: Cannot redeclare non-readonly property ParentClass::$prop as readonly Test::$prop in %s on line %d
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
--TEST--
2+
Backed readonly property get() in child class behaves as expected
3+
--FILE--
4+
<?php
5+
6+
class ParentClass {
7+
public function __construct(
8+
public readonly int $prop
9+
) {}
10+
11+
public function getParentValue() {
12+
echo "ParentClass::getParentValue(): {$this->prop}\n";
13+
var_dump($this);
14+
return $this->prop;
15+
}
16+
}
17+
18+
class ChildClass extends ParentClass {
19+
20+
public readonly int $prop {
21+
get {
22+
echo 'In ChildClass::$prop::get():' . "\n";
23+
echo ' parent::$prop::get(): ' . parent::$prop::get() . "\n";
24+
echo ' $this->prop: ' . $this->prop . "\n";
25+
echo ' $this->prop * 2: ' . $this->prop * 2 . "\n";
26+
return $this->prop * 2;
27+
}
28+
set => $value;
29+
}
30+
31+
public function setAgain() {
32+
$this->prop = 42;
33+
}
34+
}
35+
36+
$t = new ChildClass(911);
37+
38+
echo "\nFirst call:\n";
39+
$t->prop;
40+
41+
echo "\nFirst call didn't change state:\n";
42+
$t->prop;
43+
44+
echo "\nUnderlying value never touched:\n";
45+
var_dump($t);
46+
47+
echo "\nCalling scope is child, hitting child get() and child state expected:\n";
48+
$t->getParentValue();
49+
50+
try {
51+
$t->setAgain(); // cannot write, readonly
52+
} catch (Error $e) {
53+
echo $e->getMessage(), "\n";
54+
}
55+
56+
try {
57+
$t->prop = 43; // cannot write, visibility
58+
} catch (Error $e) {
59+
echo $e->getMessage(), "\n";
60+
}
61+
62+
?>
63+
--EXPECT--
64+
First call:
65+
In ChildClass::$prop::get():
66+
parent::$prop::get(): 911
67+
$this->prop: 911
68+
$this->prop * 2: 1822
69+
70+
First call didn't change state:
71+
In ChildClass::$prop::get():
72+
parent::$prop::get(): 911
73+
$this->prop: 911
74+
$this->prop * 2: 1822
75+
76+
Underlying value never touched:
77+
object(ChildClass)#1 (1) {
78+
["prop"]=>
79+
int(911)
80+
}
81+
82+
Calling scope is child, hitting child get() and child state expected:
83+
In ChildClass::$prop::get():
84+
parent::$prop::get(): 911
85+
$this->prop: 911
86+
$this->prop * 2: 1822
87+
ParentClass::getParentValue(): 1822
88+
object(ChildClass)#1 (1) {
89+
["prop"]=>
90+
int(911)
91+
}
92+
In ChildClass::$prop::get():
93+
parent::$prop::get(): 911
94+
$this->prop: 911
95+
$this->prop * 2: 1822
96+
Cannot modify readonly property ChildClass::$prop
97+
Cannot modify protected(set) readonly property ChildClass::$prop from global scope
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
--TEST--
2+
Backed promoted readonly property may have hooks
3+
--FILE--
4+
<?php
5+
6+
// readonly property
7+
class Test {
8+
public function __construct(
9+
public readonly int $prop {
10+
get => $this->prop;
11+
set => $value;
12+
}
13+
) {}
14+
15+
public function set($v)
16+
{
17+
$this->prop = $v;
18+
}
19+
}
20+
21+
$t = new Test(42);
22+
var_dump($t->prop);
23+
try {
24+
$t->set(43);
25+
} catch (Error $e) {
26+
echo $e->getMessage(), "\n";
27+
}
28+
try {
29+
$t->prop = 43;
30+
} catch (Error $e) {
31+
echo $e->getMessage(), "\n";
32+
}
33+
var_dump($t->prop);
34+
?>
35+
--EXPECT--
36+
int(42)
37+
Cannot modify readonly property Test::$prop
38+
Cannot modify protected(set) readonly property Test::$prop from global scope
39+
int(42)
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
--TEST--
2+
Readonly class Test cannot use trait with a non-readonly property
3+
--FILE--
4+
<?php
5+
6+
trait SomeTrait {
7+
public int $prop;
8+
}
9+
10+
readonly class Test {
11+
use SomeTrait;
12+
}
13+
14+
?>
15+
--EXPECTF--
16+
Fatal error: Readonly class Test cannot use trait with a non-readonly property SomeTrait::$prop in %s on line %d
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
--TEST--
2+
Virtual readonly property in interface throws
3+
--FILE--
4+
<?php
5+
6+
abstract class Test {
7+
public readonly int $prop { get; }
8+
}
9+
?>
10+
--EXPECTF--
11+
Fatal error: Hooked virtual properties cannot be readonly in %s on line %d
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
--TEST--
2+
Virtual readonly property in class throws
3+
--FILE--
4+
<?php
5+
6+
class Test {
7+
public readonly int $prop {
8+
get => 42;
9+
}
10+
}
11+
?>
12+
--EXPECTF--
13+
Fatal error: Hooked virtual properties cannot be readonly in %s on line %d
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
--TEST--
2+
Virtual readonly property in interface throws
3+
--FILE--
4+
<?php
5+
6+
interface Test {
7+
public readonly int $prop { get; }
8+
}
9+
?>
10+
--EXPECTF--
11+
Fatal error: Hooked virtual properties cannot be readonly in %s on line %d

‎Zend/zend_compile.c

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8497,8 +8497,9 @@ static void zend_compile_property_hooks(
84978497
{
84988498
zend_class_entry *ce = CG(active_class_entry);
84998499

8500-
if (prop_info->flags & ZEND_ACC_READONLY) {
8501-
zend_error_noreturn(E_COMPILE_ERROR, "Hooked properties cannot be readonly");
8500+
/* Allow hooks on backed readonly properties only. */
8501+
if ((prop_info->flags & (ZEND_ACC_READONLY|ZEND_ACC_VIRTUAL)) == (ZEND_ACC_READONLY|ZEND_ACC_VIRTUAL)) {
8502+
zend_error_noreturn(E_COMPILE_ERROR, "Hooked virtual properties cannot be readonly");
85028503
}
85038504

85048505
if (hooks->children == 0) {

0 commit comments

Comments
 (0)
Please sign in to comment.