Commit a2640aebc91 for php.net
commit a2640aebc914b3c6fc997c379b1f91a2c4db8c55
Author: NickSdot <32384907+NickSdot@users.noreply.github.com>
Date: Wed Aug 12 02:01:17 2026 +0700
[RFC] Allow Readonly Property Defaults (GH-22588)
Co-authored-by: DanielEScherzer <daniel.e.scherzer@gmail.com>
diff --git a/NEWS b/NEWS
index a2122027513..d9f1281d594 100644
--- a/NEWS
+++ b/NEWS
@@ -16,6 +16,7 @@ PHP NEWS
. Changed run-tests.php to run in parallel by default, using up to 10
automatically detected workers. Pass -j1 for sequential execution.
(NickSdot)
+ . Allowed readonly properties to declare default values. (NickSdot)
. Changed run-tests.php to run test subprocesses without a shell where
possible. (NickSdot)
. Fixed GH-23083 (SEGV build_trace_args in zend_exceptions.c with
diff --git a/UPGRADING b/UPGRADING
index 779c95ae953..f82aad3f8b3 100644
--- a/UPGRADING
+++ b/UPGRADING
@@ -299,6 +299,8 @@ PHP 8.6 UPGRADE NOTES
========================================
- Core:
+ . Readonly properties may now declare default values.
+ RFC: https://wiki.php.net/rfc/readonly_property_defaults
. It is now possible to use reference assign on WeakMap without the key
needing to be present beforehand.
. It is now possible to define the __debugInfo() magic method on enums.
diff --git a/Zend/tests/readonly_classes/readonly_with_property_default.phpt b/Zend/tests/readonly_classes/readonly_with_property_default.phpt
new file mode 100644
index 00000000000..49d2bdf03a1
--- /dev/null
+++ b/Zend/tests/readonly_classes/readonly_with_property_default.phpt
@@ -0,0 +1,36 @@
+--TEST--
+Properties of a readonly class may have default values
+--FILE--
+<?php
+
+readonly class Foo
+{
+ public int $bar = 1;
+ public ?string $nullable = null;
+
+ public function __construct()
+ {
+ try {
+ $this->bar = 2;
+ } catch (Error $e) {
+ echo $e::class, ': ', $e->getMessage(), PHP_EOL;
+ }
+ }
+}
+
+$foo = new Foo();
+var_dump($foo->bar);
+var_dump($foo->nullable);
+
+try {
+ $foo->bar = 3;
+} catch (Error $e) {
+ echo $e::class, ': ', $e->getMessage(), PHP_EOL;
+}
+
+?>
+--EXPECT--
+Error: Cannot modify readonly property Foo::$bar
+int(1)
+NULL
+Error: Cannot modify readonly property Foo::$bar
diff --git a/Zend/tests/readonly_classes/readonly_with_property_default_trait.phpt b/Zend/tests/readonly_classes/readonly_with_property_default_trait.phpt
new file mode 100644
index 00000000000..9decd22a8ba
--- /dev/null
+++ b/Zend/tests/readonly_classes/readonly_with_property_default_trait.phpt
@@ -0,0 +1,25 @@
+--TEST--
+Readonly class may use readonly trait property with default value
+--FILE--
+<?php
+
+trait TDefault {
+ public readonly int $prop = 2;
+}
+
+readonly class A {
+ use TDefault;
+}
+
+var_dump(new A()->prop);
+
+class B {
+ use TDefault;
+}
+
+var_dump(new B()->prop);
+
+?>
+--EXPECT--
+int(2)
+int(2)
diff --git a/Zend/tests/readonly_props/readonly_clone_success1.phpt b/Zend/tests/readonly_props/readonly_clone_success1.phpt
index 72cd9e9622b..50e5e6df81e 100644
--- a/Zend/tests/readonly_props/readonly_clone_success1.phpt
+++ b/Zend/tests/readonly_props/readonly_clone_success1.phpt
@@ -23,6 +23,41 @@ public function __clone()
var_dump(clone $foo2);
+class FooWithDefault {
+ public readonly int $bar = 1;
+
+ public function __clone()
+ {
+ $this->bar++;
+ }
+}
+
+$fooWithDefault = new FooWithDefault();
+
+var_dump(clone $fooWithDefault);
+
+$fooWithDefault2 = clone $fooWithDefault;
+var_dump($fooWithDefault2);
+
+var_dump(clone $fooWithDefault2);
+
+class FooWithDefaultCloneWith {
+ public readonly int $bar = 1;
+
+ public function withBar(int $bar)
+ {
+ return clone($this, ['bar' => $bar]);
+ }
+}
+
+$clone = new FooWithDefaultCloneWith();
+var_dump($clone);
+
+$clone2 = $clone->withBar(2);
+var_dump($clone2);
+
+var_dump($clone2->withBar(0));
+
?>
--EXPECTF--
object(Foo)#%d (%d) {
@@ -37,3 +72,27 @@ public function __clone()
["bar"]=>
int(3)
}
+object(FooWithDefault)#%d (%d) {
+ ["bar"]=>
+ int(2)
+}
+object(FooWithDefault)#%d (%d) {
+ ["bar"]=>
+ int(2)
+}
+object(FooWithDefault)#%d (%d) {
+ ["bar"]=>
+ int(3)
+}
+object(FooWithDefaultCloneWith)#%d (%d) {
+ ["bar"]=>
+ int(1)
+}
+object(FooWithDefaultCloneWith)#%d (%d) {
+ ["bar"]=>
+ int(2)
+}
+object(FooWithDefaultCloneWith)#%d (%d) {
+ ["bar"]=>
+ int(0)
+}
diff --git a/Zend/tests/readonly_props/readonly_trait_match.phpt b/Zend/tests/readonly_props/readonly_trait_match.phpt
index 00aa6349aa1..36f2e11ec2b 100644
--- a/Zend/tests/readonly_props/readonly_trait_match.phpt
+++ b/Zend/tests/readonly_props/readonly_trait_match.phpt
@@ -13,7 +13,20 @@ class C {
use T1, T2;
}
+trait TDefault1 {
+ public readonly int $prop = 1;
+}
+trait TDefault2 {
+ public readonly int $prop = 1;
+}
+class CDefault {
+ use TDefault1, TDefault2;
+}
+
+var_dump(new CDefault()->prop);
+
?>
===DONE===
--EXPECT--
+int(1)
===DONE===
diff --git a/Zend/tests/readonly_props/readonly_with_default.phpt b/Zend/tests/readonly_props/readonly_with_default.phpt
index 12afe5cde15..affe62f8d1f 100644
--- a/Zend/tests/readonly_props/readonly_with_default.phpt
+++ b/Zend/tests/readonly_props/readonly_with_default.phpt
@@ -3,17 +3,43 @@
--FILE--
<?php
+enum E {
+ case Case;
+}
+
class Test {
public readonly int $prop = 1;
+ public readonly string $className = self::class;
+ public readonly ?string $nullable = null;
+ public readonly array $array = [1, "two" => 2];
+ public readonly E $enum = E::Case;
+ public readonly string $enumString = E::Case->name;
}
$test = new Test;
+var_dump($test->prop);
+var_dump($test->className);
+var_dump($test->nullable);
+var_dump($test->array);
+var_dump($test->enum);
+var_dump($test->enumString);
try {
$test->prop = 2;
} catch (Error $e) {
- echo $e->getMessage(), "\n";
+ echo $e::class, ': ', $e->getMessage(), PHP_EOL;
}
?>
---EXPECTF--
-Fatal error: Readonly property Test::$prop cannot have default value in %s on line %d
+--EXPECT--
+int(1)
+string(4) "Test"
+NULL
+array(2) {
+ [0]=>
+ int(1)
+ ["two"]=>
+ int(2)
+}
+enum(E::Case)
+string(4) "Case"
+Error: Cannot modify readonly property Test::$prop
diff --git a/Zend/tests/readonly_props/readonly_with_default_abstract_get_set_implicit_set.phpt b/Zend/tests/readonly_props/readonly_with_default_abstract_get_set_implicit_set.phpt
new file mode 100644
index 00000000000..e61574d07aa
--- /dev/null
+++ b/Zend/tests/readonly_props/readonly_with_default_abstract_get_set_implicit_set.phpt
@@ -0,0 +1,18 @@
+--TEST--
+Readonly property with default value has restricted set visibility for get/set abstract property
+--DESCRIPTION--
+The error message should be improved, the set access level comes from readonly.
+--FILE--
+<?php
+
+abstract class P {
+ public abstract int $prop { get; set; }
+}
+
+class C extends P {
+ public readonly int $prop = 42;
+}
+
+?>
+--EXPECTF--
+Fatal error: Set access level of C::$prop must be omitted (as in class P) in %s on line %d
diff --git a/Zend/tests/readonly_props/readonly_with_default_asymmetric_visibility.phpt b/Zend/tests/readonly_props/readonly_with_default_asymmetric_visibility.phpt
new file mode 100644
index 00000000000..11b5a741ca1
--- /dev/null
+++ b/Zend/tests/readonly_props/readonly_with_default_asymmetric_visibility.phpt
@@ -0,0 +1,34 @@
+--TEST--
+Readonly property with default value and asymmetric visibility
+--FILE--
+<?php
+
+class Test {
+ public readonly int $default = 1;
+ public private(set) readonly int $private = 2;
+ public protected(set) readonly int $protected = 3;
+ public public(set) readonly int $public = 4;
+}
+
+$test = new Test();
+
+foreach (['default', 'private', 'protected', 'public'] as $prop) {
+ $before = $test->$prop;
+ try {
+ $test->$prop = 42;
+ } catch (Error $e) {
+ echo $e::class, ': ', $e->getMessage(), PHP_EOL;
+ }
+ echo "$$prop before $before, after {$test->$prop}", PHP_EOL;
+}
+
+?>
+--EXPECT--
+Error: Cannot modify readonly property Test::$default
+$default before 1, after 1
+Error: Cannot modify readonly property Test::$private
+$private before 2, after 2
+Error: Cannot modify readonly property Test::$protected
+$protected before 3, after 3
+Error: Cannot modify readonly property Test::$public
+$public before 4, after 4
diff --git a/Zend/tests/readonly_props/readonly_with_default_inheritance.phpt b/Zend/tests/readonly_props/readonly_with_default_inheritance.phpt
new file mode 100644
index 00000000000..8b285dcdd76
--- /dev/null
+++ b/Zend/tests/readonly_props/readonly_with_default_inheritance.phpt
@@ -0,0 +1,40 @@
+--TEST--
+Readonly property with default value and inheritance
+--FILE--
+<?php
+
+class ParentDefault {
+ public readonly int $prop = 1;
+}
+
+class ChildInherits extends ParentDefault {}
+
+class ChildOverrides extends ParentDefault {
+ public readonly int $prop = 2;
+}
+
+class PrivateParent {
+ private readonly int $prop = 3;
+
+ public function getParentProp(): int {
+ return $this->prop;
+ }
+}
+
+class PrivateChild extends PrivateParent {
+ public readonly int $prop = 4;
+}
+
+var_dump(new ChildInherits()->prop);
+var_dump(new ChildOverrides()->prop);
+
+$privateChild = new PrivateChild();
+var_dump($privateChild->getParentProp());
+var_dump($privateChild->prop);
+
+?>
+--EXPECT--
+int(1)
+int(2)
+int(3)
+int(4)
diff --git a/Zend/tests/readonly_props/readonly_with_default_interface_get_only.phpt b/Zend/tests/readonly_props/readonly_with_default_interface_get_only.phpt
new file mode 100644
index 00000000000..1e64e26b451
--- /dev/null
+++ b/Zend/tests/readonly_props/readonly_with_default_interface_get_only.phpt
@@ -0,0 +1,17 @@
+--TEST--
+Readonly property with default value satisfies get-only interface property
+--FILE--
+<?php
+
+interface I {
+ public int $prop { get; }
+}
+
+class C implements I {
+ public readonly int $prop = 42;
+}
+
+var_dump(new C()->prop);
+?>
+--EXPECT--
+int(42)
diff --git a/Zend/tests/readonly_props/readonly_with_default_interface_get_set.phpt b/Zend/tests/readonly_props/readonly_with_default_interface_get_set.phpt
new file mode 100644
index 00000000000..102444cc044
--- /dev/null
+++ b/Zend/tests/readonly_props/readonly_with_default_interface_get_set.phpt
@@ -0,0 +1,16 @@
+--TEST--
+Readonly public(set) property with default value does not satisfy get/set interface property
+--FILE--
+<?php
+
+interface I {
+ public int $prop { get; set; }
+}
+
+// does not satisfy set
+class C implements I {
+ public public(set) readonly int $prop = 42;
+}
+?>
+--EXPECTF--
+Fatal error: Class C contains 1 abstract method and must therefore be declared abstract or implement the remaining method (I::$prop::set) in %s on line %d
diff --git a/Zend/tests/readonly_props/readonly_with_default_interface_get_set_implicit_set.phpt b/Zend/tests/readonly_props/readonly_with_default_interface_get_set_implicit_set.phpt
new file mode 100644
index 00000000000..106bf2aeb0c
--- /dev/null
+++ b/Zend/tests/readonly_props/readonly_with_default_interface_get_set_implicit_set.phpt
@@ -0,0 +1,17 @@
+--TEST--
+Readonly property with default value has restricted set visibility for get/set interface property
+--DESCRIPTION--
+The error message should be improved, the set access level comes from readonly. Ref: Zend/tests/property_hooks/interface_get_set_readonly.phpt
+--FILE--
+<?php
+
+interface I {
+ public int $prop { get; set; }
+}
+
+class C implements I {
+ public readonly int $prop = 42;
+}
+?>
+--EXPECTF--
+Fatal error: Set access level of C::$prop must be omitted (as in class I) in %s on line %d
diff --git a/Zend/tests/readonly_props/readonly_with_default_trait_mismatch.phpt b/Zend/tests/readonly_props/readonly_with_default_trait_mismatch.phpt
new file mode 100644
index 00000000000..6668b1d860b
--- /dev/null
+++ b/Zend/tests/readonly_props/readonly_with_default_trait_mismatch.phpt
@@ -0,0 +1,20 @@
+--TEST--
+Readonly trait property default value mismatch
+--FILE--
+<?php
+
+trait T1 {
+ public readonly int $prop = 1;
+}
+
+trait T2 {
+ public readonly int $prop = 2;
+}
+
+class C {
+ use T1, T2;
+}
+
+?>
+--EXPECTF--
+Fatal error: T1 and T2 define the same property ($prop) in the composition of C. However, the definition differs and is considered incompatible. Class was composed in %s on line %d
diff --git a/Zend/tests/readonly_props/serialization.phpt b/Zend/tests/readonly_props/serialization.phpt
index f9e1f364673..0d56067760d 100644
--- a/Zend/tests/readonly_props/serialization.phpt
+++ b/Zend/tests/readonly_props/serialization.phpt
@@ -17,6 +17,42 @@ public function __construct(
var_dump(unserialize("O:4:\"Test\":1:{s:4:\"prop\";i:2;}"));
var_dump(unserialize("O:4:\"Test\":2:{s:4:\"prop\";i:2;s:4:\"prop\";i:3;}"));
+class TestDefault {
+ public readonly int $prop = 1;
+}
+
+var_dump($s = serialize(new TestDefault));
+var_dump(unserialize($s));
+
+var_dump(unserialize("O:11:\"TestDefault\":0:{}"));
+var_dump(unserialize("O:11:\"TestDefault\":1:{s:4:\"prop\";i:2;}"));
+
+class TestDefaultWithUnserialize {
+ public readonly int $prop = 1;
+ public public(set) readonly int $lock = 1;
+
+ public function __unserialize(array $data): void {
+ foreach ($data as $key => $value) {
+ $this->{$key} = $value;
+ }
+ }
+}
+
+$testDefaultWithUnserialize = unserialize("O:26:\"TestDefaultWithUnserialize\":1:{s:4:\"prop\";i:2;}");
+var_dump($testDefaultWithUnserialize);
+
+try {
+ $testDefaultWithUnserialize->prop = 3;
+} catch (Error $e) {
+ echo $e::class, ': ', $e->getMessage(), PHP_EOL;
+}
+
+try {
+ $testDefaultWithUnserialize->lock = 3;
+} catch (Error $e) {
+ echo $e::class, ': ', $e->getMessage(), PHP_EOL;
+}
+
?>
--EXPECT--
string(30) "O:4:"Test":1:{s:4:"prop";i:1;}"
@@ -32,3 +68,24 @@ public function __construct(
["prop"]=>
int(3)
}
+string(38) "O:11:"TestDefault":1:{s:4:"prop";i:1;}"
+object(TestDefault)#1 (1) {
+ ["prop"]=>
+ int(1)
+}
+object(TestDefault)#1 (1) {
+ ["prop"]=>
+ int(1)
+}
+object(TestDefault)#1 (1) {
+ ["prop"]=>
+ int(2)
+}
+object(TestDefaultWithUnserialize)#1 (2) {
+ ["prop"]=>
+ int(2)
+ ["lock"]=>
+ int(1)
+}
+Error: Cannot modify readonly property TestDefaultWithUnserialize::$prop
+Error: Cannot modify readonly property TestDefaultWithUnserialize::$lock
diff --git a/Zend/tests/readonly_props/unset.phpt b/Zend/tests/readonly_props/unset.phpt
index b8bd4218fa0..823c021f612 100644
--- a/Zend/tests/readonly_props/unset.phpt
+++ b/Zend/tests/readonly_props/unset.phpt
@@ -54,6 +54,31 @@ class Test3 {
echo $e->getMessage(), "\n";
}
+class Test4 {
+ public readonly int $prop = 1;
+
+ public function __construct() {
+ try {
+ unset($this->prop);
+ } catch (Error $e) {
+ echo $e::class, ': ', $e->getMessage(), PHP_EOL;
+ }
+ }
+
+ public function __get($name) {
+ throw new Exception('Unreachable');
+ }
+}
+
+$test = new Test4;
+var_dump($test->prop); // Don't call __get.
+try {
+ unset($test->prop);
+} catch (Error $e) {
+ echo $e::class, ': ', $e->getMessage(), PHP_EOL;
+}
+var_dump($test->prop); // Still don't call __get.
+
?>
--EXPECT--
Cannot unset readonly property Test::$prop
@@ -62,3 +87,7 @@ class Test3 {
int(1)
Cannot unset readonly property Test2::$prop
Cannot unset protected(set) readonly property Test3::$prop from global scope
+Error: Cannot unset readonly property Test4::$prop
+int(1)
+Error: Cannot unset readonly property Test4::$prop
+int(1)
diff --git a/Zend/zend_compile.c b/Zend/zend_compile.c
index 6fd4df02352..1d837c59832 100644
--- a/Zend/zend_compile.c
+++ b/Zend/zend_compile.c
@@ -9497,11 +9497,6 @@ static void zend_compile_prop_decl(zend_ast *ast, zend_ast *type_ast, uint32_t f
zend_error_noreturn(E_COMPILE_ERROR, "Readonly property %s::$%s must have type",
ZSTR_VAL(ce->name), ZSTR_VAL(name));
}
- if (!Z_ISUNDEF(value_zv)) {
- zend_error_noreturn(E_COMPILE_ERROR,
- "Readonly property %s::$%s cannot have default value",
- ZSTR_VAL(ce->name), ZSTR_VAL(name));
- }
if (flags & ZEND_ACC_STATIC) {
zend_error_noreturn(E_COMPILE_ERROR,
"Static property %s::$%s cannot be readonly",
diff --git a/ext/reflection/tests/ReflectionClass_toString_009.phpt b/ext/reflection/tests/ReflectionClass_toString_009.phpt
new file mode 100644
index 00000000000..b2d118c459b
--- /dev/null
+++ b/ext/reflection/tests/ReflectionClass_toString_009.phpt
@@ -0,0 +1,32 @@
+--TEST--
+ReflectionClass::__toString() - readonly property with default
+--FILE--
+<?php
+
+class Test {
+ public readonly int $property = 42;
+}
+
+echo new ReflectionClass(Test::class);
+
+?>
+--EXPECTF--
+Class [ <user> class Test ] {
+ @@ %s 3-5
+
+ - Constants [0] {
+ }
+
+ - Static properties [0] {
+ }
+
+ - Static methods [0] {
+ }
+
+ - Properties [1] {
+ Property [ public protected(set) readonly int $property = 42 ]
+ }
+
+ - Methods [0] {
+ }
+}
diff --git a/ext/reflection/tests/ReflectionProperty_readonly_with_default.phpt b/ext/reflection/tests/ReflectionProperty_readonly_with_default.phpt
new file mode 100644
index 00000000000..d3c3e8a084e
--- /dev/null
+++ b/ext/reflection/tests/ReflectionProperty_readonly_with_default.phpt
@@ -0,0 +1,35 @@
+--TEST--
+Reflection for readonly property with default value
+--FILE--
+<?php
+
+class Foo {
+ public readonly int $prop = 1;
+ public readonly ?string $nullable = null;
+}
+
+$rp = new ReflectionProperty(Foo::class, 'prop');
+var_dump($rp->isReadOnly());
+var_dump($rp->hasDefaultValue());
+var_dump($rp->getDefaultValue());
+var_dump(new ReflectionClass(Foo::class)->getDefaultProperties());
+
+$test = new Foo();
+try {
+ $rp->setValue($test, 2);
+} catch (Error $e) {
+ echo $e::class, ': ', $e->getMessage(), PHP_EOL;
+}
+
+?>
+--EXPECT--
+bool(true)
+bool(true)
+int(1)
+array(2) {
+ ["prop"]=>
+ int(1)
+ ["nullable"]=>
+ NULL
+}
+Error: Cannot modify readonly property Foo::$prop
diff --git a/ext/reflection/tests/ReflectionProperty_toString_002.phpt b/ext/reflection/tests/ReflectionProperty_toString_002.phpt
new file mode 100644
index 00000000000..5b93d2149b9
--- /dev/null
+++ b/ext/reflection/tests/ReflectionProperty_toString_002.phpt
@@ -0,0 +1,14 @@
+--TEST--
+ReflectionProperty::__toString() - readonly with default
+--FILE--
+<?php
+
+class Test {
+ public readonly int $nick = 42;
+}
+
+echo new ReflectionProperty(Test::class, 'nick');
+
+?>
+--EXPECT--
+Property [ public protected(set) readonly int $nick = 42 ]
diff --git a/ext/standard/var_unserializer.re b/ext/standard/var_unserializer.re
index eca9660c560..1fb8793c2bb 100644
--- a/ext/standard/var_unserializer.re
+++ b/ext/standard/var_unserializer.re
@@ -17,6 +17,7 @@
#include "php_incomplete_class.h"
#include "zend_portability.h"
#include "zend_exceptions.h"
+#include "zend_objects.h"
/* {{{ reference-handling for unserializer: var_* */
#define VAR_ENTRIES_MAX 1018 /* 1024 - offsetof(php_unserialize_data, entries) / sizeof(void*) */
@@ -300,6 +301,7 @@ PHPAPI void var_destroy(php_unserialize_data_t *var_hashx)
zval param;
ZVAL_COPY(¶m, &var_dtor_hash->data[i + 1]);
+ zend_object_set_properties_reinitable(Z_OBJ_P(zv), /* reinitable */ true);
BG(serialize_lock)++;
zend_call_known_instance_method_with_1_params(
Z_OBJCE_P(zv)->__unserialize, Z_OBJ_P(zv), NULL, ¶m);
@@ -308,6 +310,7 @@ PHPAPI void var_destroy(php_unserialize_data_t *var_hashx)
GC_ADD_FLAGS(Z_OBJ_P(zv), IS_OBJ_DESTRUCTOR_CALLED);
}
BG(serialize_lock)--;
+ zend_object_set_properties_reinitable(Z_OBJ_P(zv), /* reinitable */ false);
zval_ptr_dtor(¶m);
} else {
GC_ADD_FLAGS(Z_OBJ_P(zv), IS_OBJ_DESTRUCTOR_CALLED);