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(&param, &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, &param);
@@ -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(&param);
 				} else {
 					GC_ADD_FLAGS(Z_OBJ_P(zv), IS_OBJ_DESTRUCTOR_CALLED);