From 13c3f42c2c73313111f4caabc6df28cdb327a253 Mon Sep 17 00:00:00 2001 From: NickSdot Date: Sat, 4 Jul 2026 14:02:10 +0700 Subject: [PATCH 1/7] Allowed defaults on readonly properties --- .../readonly_class_inheritance_success.phpt | 11 +++++ .../readonly_with_property_default.phpt | 36 +++++++++++++++++ .../readonly_with_property_default_trait.phpt | 18 +++++++++ .../readonly_clone_success1.phpt | 30 ++++++++++++++ .../readonly_props/readonly_modification.phpt | 16 ++++++++ .../readonly_props/readonly_trait_match.phpt | 13 ++++++ .../readonly_props/readonly_with_default.phpt | 30 +++++++++++++- ...ly_with_default_asymmetric_visibility.phpt | 33 +++++++++++++++ .../readonly_with_default_inheritance.phpt | 40 +++++++++++++++++++ .../readonly_with_default_interface.phpt | 29 ++++++++++++++ .../readonly_with_default_reflection.phpt | 35 ++++++++++++++++ .../readonly_with_default_trait_mismatch.phpt | 20 ++++++++++ Zend/tests/readonly_props/serialization.phpt | 29 ++++++++++++++ Zend/tests/readonly_props/unset.phpt | 31 ++++++++++++++ Zend/zend_compile.c | 5 --- 15 files changed, 369 insertions(+), 7 deletions(-) create mode 100644 Zend/tests/readonly_classes/readonly_with_property_default.phpt create mode 100644 Zend/tests/readonly_classes/readonly_with_property_default_trait.phpt create mode 100644 Zend/tests/readonly_props/readonly_with_default_asymmetric_visibility.phpt create mode 100644 Zend/tests/readonly_props/readonly_with_default_inheritance.phpt create mode 100644 Zend/tests/readonly_props/readonly_with_default_interface.phpt create mode 100644 Zend/tests/readonly_props/readonly_with_default_reflection.phpt create mode 100644 Zend/tests/readonly_props/readonly_with_default_trait_mismatch.phpt diff --git a/Zend/tests/readonly_classes/readonly_class_inheritance_success.phpt b/Zend/tests/readonly_classes/readonly_class_inheritance_success.phpt index 33e73554ce01..133152ca6e91 100644 --- a/Zend/tests/readonly_classes/readonly_class_inheritance_success.phpt +++ b/Zend/tests/readonly_classes/readonly_class_inheritance_success.phpt @@ -5,11 +5,22 @@ Readonly class can extend a readonly class readonly class Foo { + public int $prop = 1; } readonly class Bar extends Foo { + public int $prop = 2; } +readonly class Baz extends Foo {} + +var_dump(new Foo()->prop); +var_dump(new Bar()->prop); +var_dump(new Baz()->prop); + ?> --EXPECT-- +int(1) +int(2) +int(1) 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 000000000000..b6e38e23e8b0 --- /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-- +bar = 2; + } catch (Error $e) { + echo $e->getMessage(), "\n"; + } + } +} + +$foo = new Foo(); +var_dump($foo->bar); +var_dump($foo->nullable); + +try { + $foo->bar = 3; +} catch (Error $e) { + echo $e->getMessage(), "\n"; +} + +?> +--EXPECT-- +Cannot modify readonly property Foo::$bar +int(1) +NULL +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 000000000000..e96945e0992e --- /dev/null +++ b/Zend/tests/readonly_classes/readonly_with_property_default_trait.phpt @@ -0,0 +1,18 @@ +--TEST-- +Readonly class may use readonly trait property with default value +--FILE-- +prop); + +?> +--EXPECT-- +int(2) diff --git a/Zend/tests/readonly_props/readonly_clone_success1.phpt b/Zend/tests/readonly_props/readonly_clone_success1.phpt index 72cd9e9622b3..ab6ee3157332 100644 --- a/Zend/tests/readonly_props/readonly_clone_success1.phpt +++ b/Zend/tests/readonly_props/readonly_clone_success1.phpt @@ -23,6 +23,24 @@ var_dump($foo2); 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); + ?> --EXPECTF-- object(Foo)#%d (%d) { @@ -37,3 +55,15 @@ object(Foo)#%d (%d) { ["bar"]=> int(3) } +object(FooWithDefault)#%d (%d) { + ["bar"]=> + int(2) +} +object(FooWithDefault)#%d (%d) { + ["bar"]=> + int(2) +} +object(FooWithDefault)#%d (%d) { + ["bar"]=> + int(3) +} diff --git a/Zend/tests/readonly_props/readonly_modification.phpt b/Zend/tests/readonly_props/readonly_modification.phpt index bd04a203be19..67199cb6ec1a 100644 --- a/Zend/tests/readonly_props/readonly_modification.phpt +++ b/Zend/tests/readonly_props/readonly_modification.phpt @@ -14,6 +14,18 @@ class Test { } } +class TestWithDefault { + public readonly int $prop = 1; + + public function __construct() { + try { + $this->prop = 2; + } catch (Error $e) { + echo $e->getMessage(), "\n"; + } + } +} + function byRef(&$ref) {} $test = new Test; @@ -66,6 +78,8 @@ try { echo $e->getMessage(), "\n"; } +var_dump(new TestWithDefault()->prop); + ?> --EXPECT-- int(1) @@ -80,3 +94,5 @@ array(0) { } Cannot indirectly modify readonly property Test::$prop2 Cannot indirectly modify readonly property Test::$prop2 +Cannot modify readonly property TestWithDefault::$prop +int(1) diff --git a/Zend/tests/readonly_props/readonly_trait_match.phpt b/Zend/tests/readonly_props/readonly_trait_match.phpt index 00aa6349aa14..36f2e11ec2bc 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 12afe5cde153..84977bfda1c3 100644 --- a/Zend/tests/readonly_props/readonly_with_default.phpt +++ b/Zend/tests/readonly_props/readonly_with_default.phpt @@ -3,11 +3,26 @@ Readonly property with default value --FILE-- 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) { @@ -15,5 +30,16 @@ try { } ?> ---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" +Cannot modify readonly property Test::$prop 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 000000000000..3d31f098f402 --- /dev/null +++ b/Zend/tests/readonly_props/readonly_with_default_asymmetric_visibility.phpt @@ -0,0 +1,33 @@ +--TEST-- +Readonly property with default value and asymmetric visibility +--FILE-- +default, $test->private, $test->protected, $test->public); + +foreach (['default', 'private', 'protected', 'public'] as $prop) { + try { + $test->$prop = 42; + } catch (Error $e) { + echo $e->getMessage(), "\n"; + } +} + +?> +--EXPECT-- +int(1) +int(2) +int(3) +int(4) +Cannot modify readonly property Test::$default +Cannot modify readonly property Test::$private +Cannot modify readonly property Test::$protected +Cannot modify readonly property Test::$public 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 000000000000..8b285dcdd769 --- /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-- +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.phpt b/Zend/tests/readonly_props/readonly_with_default_interface.phpt new file mode 100644 index 000000000000..a20d869dc661 --- /dev/null +++ b/Zend/tests/readonly_props/readonly_with_default_interface.phpt @@ -0,0 +1,29 @@ +--TEST-- +Readonly property with default value satisfies get-only interface property +--FILE-- +prop); + +interface J { + public int $prop { get; set; } +} + +// does not satisfy set +class D implements J { + public readonly int $prop = 42; +} + +?> +--EXPECTF-- +int(42) + +Fatal error: Set access level of D::$prop must be omitted (as in class J) in %s on line %d diff --git a/Zend/tests/readonly_props/readonly_with_default_reflection.phpt b/Zend/tests/readonly_props/readonly_with_default_reflection.phpt new file mode 100644 index 000000000000..fd1df1ebff29 --- /dev/null +++ b/Zend/tests/readonly_props/readonly_with_default_reflection.phpt @@ -0,0 +1,35 @@ +--TEST-- +Reflection for readonly property with default value +--FILE-- +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->getMessage(), "\n"; +} + +?> +--EXPECT-- +bool(true) +bool(true) +int(1) +array(2) { + ["prop"]=> + int(1) + ["nullable"]=> + NULL +} +Cannot modify readonly property Foo::$prop 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 000000000000..6668b1d860bb --- /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-- + +--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 f9e1f364673f..f51fce199a63 100644 --- a/Zend/tests/readonly_props/serialization.phpt +++ b/Zend/tests/readonly_props/serialization.phpt @@ -17,6 +17,18 @@ var_dump(unserialize($s)); 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)); + +// Unserialization still bypasses normal readonly assignment semantics. +var_dump(unserialize("O:11:\"TestDefault\":0:{}")); +var_dump(unserialize("O:11:\"TestDefault\":1:{s:4:\"prop\";i:2;}")); +var_dump(unserialize("O:11:\"TestDefault\":2:{s:4:\"prop\";i:2;s:4:\"prop\";i:3;}")); + ?> --EXPECT-- string(30) "O:4:"Test":1:{s:4:"prop";i:1;}" @@ -32,3 +44,20 @@ object(Test)#1 (1) { ["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(TestDefault)#1 (1) { + ["prop"]=> + int(3) +} diff --git a/Zend/tests/readonly_props/unset.phpt b/Zend/tests/readonly_props/unset.phpt index b8bd4218fa0c..ee86881d288c 100644 --- a/Zend/tests/readonly_props/unset.phpt +++ b/Zend/tests/readonly_props/unset.phpt @@ -54,6 +54,33 @@ try { echo $e->getMessage(), "\n"; } +class Test4 { + public readonly int $prop = 1; + + public function __construct() { + try { + unset($this->prop); + } catch (Error $e) { + echo $e->getMessage(), "\n"; + } + } + + public function __get($name) { + echo __METHOD__, "\n"; // lazy pattern does not work + $this->prop = 2; + return $this->prop; + } +} + +$test = new Test4; +var_dump($test->prop); // Don't call __get. +try { + unset($test->prop); +} catch (Error $e) { + echo $e->getMessage(), "\n"; +} +var_dump($test->prop); // Still don't call __get. + ?> --EXPECT-- Cannot unset readonly property Test::$prop @@ -62,3 +89,7 @@ int(1) int(1) Cannot unset readonly property Test2::$prop Cannot unset protected(set) readonly property Test3::$prop from global scope +Cannot unset readonly property Test4::$prop +int(1) +Cannot unset readonly property Test4::$prop +int(1) diff --git a/Zend/zend_compile.c b/Zend/zend_compile.c index ac5a9d71a6ea..7add834b9a8f 100644 --- a/Zend/zend_compile.c +++ b/Zend/zend_compile.c @@ -9247,11 +9247,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", From 693e7e3f8a5376df345054588cf5ae5c5bde39ae Mon Sep 17 00:00:00 2001 From: NickSdot Date: Sun, 5 Jul 2026 01:02:07 +0700 Subject: [PATCH 2/7] Added error classes to new test cases --- .../readonly_with_property_default.phpt | 8 ++++---- Zend/tests/readonly_props/readonly_modification.phpt | 4 ++-- Zend/tests/readonly_props/readonly_with_default.phpt | 4 ++-- .../readonly_with_default_asymmetric_visibility.phpt | 10 +++++----- .../readonly_with_default_reflection.phpt | 4 ++-- Zend/tests/readonly_props/unset.phpt | 8 ++++---- 6 files changed, 19 insertions(+), 19 deletions(-) diff --git a/Zend/tests/readonly_classes/readonly_with_property_default.phpt b/Zend/tests/readonly_classes/readonly_with_property_default.phpt index b6e38e23e8b0..4268ff5734a4 100644 --- a/Zend/tests/readonly_classes/readonly_with_property_default.phpt +++ b/Zend/tests/readonly_classes/readonly_with_property_default.phpt @@ -13,7 +13,7 @@ readonly class Foo try { $this->bar = 2; } catch (Error $e) { - echo $e->getMessage(), "\n"; + echo $e::class, ": ", $e->getMessage(), "\n"; } } } @@ -25,12 +25,12 @@ var_dump($foo->nullable); try { $foo->bar = 3; } catch (Error $e) { - echo $e->getMessage(), "\n"; + echo $e::class, ": ", $e->getMessage(), "\n"; } ?> --EXPECT-- -Cannot modify readonly property Foo::$bar +Error: Cannot modify readonly property Foo::$bar int(1) NULL -Cannot modify readonly property Foo::$bar +Error: Cannot modify readonly property Foo::$bar diff --git a/Zend/tests/readonly_props/readonly_modification.phpt b/Zend/tests/readonly_props/readonly_modification.phpt index 67199cb6ec1a..2e13ad3fb408 100644 --- a/Zend/tests/readonly_props/readonly_modification.phpt +++ b/Zend/tests/readonly_props/readonly_modification.phpt @@ -21,7 +21,7 @@ class TestWithDefault { try { $this->prop = 2; } catch (Error $e) { - echo $e->getMessage(), "\n"; + echo $e::class, ": ", $e->getMessage(), "\n"; } } } @@ -94,5 +94,5 @@ array(0) { } Cannot indirectly modify readonly property Test::$prop2 Cannot indirectly modify readonly property Test::$prop2 -Cannot modify readonly property TestWithDefault::$prop +Error: Cannot modify readonly property TestWithDefault::$prop int(1) diff --git a/Zend/tests/readonly_props/readonly_with_default.phpt b/Zend/tests/readonly_props/readonly_with_default.phpt index 84977bfda1c3..f8e381576e56 100644 --- a/Zend/tests/readonly_props/readonly_with_default.phpt +++ b/Zend/tests/readonly_props/readonly_with_default.phpt @@ -26,7 +26,7 @@ var_dump($test->enumString); try { $test->prop = 2; } catch (Error $e) { - echo $e->getMessage(), "\n"; + echo $e::class, ": ", $e->getMessage(), "\n"; } ?> @@ -42,4 +42,4 @@ array(2) { } enum(E::Case) string(4) "Case" -Cannot modify readonly property Test::$prop +Error: Cannot modify readonly property Test::$prop diff --git a/Zend/tests/readonly_props/readonly_with_default_asymmetric_visibility.phpt b/Zend/tests/readonly_props/readonly_with_default_asymmetric_visibility.phpt index 3d31f098f402..a61373edc388 100644 --- a/Zend/tests/readonly_props/readonly_with_default_asymmetric_visibility.phpt +++ b/Zend/tests/readonly_props/readonly_with_default_asymmetric_visibility.phpt @@ -17,7 +17,7 @@ foreach (['default', 'private', 'protected', 'public'] as $prop) { try { $test->$prop = 42; } catch (Error $e) { - echo $e->getMessage(), "\n"; + echo $e::class, ": ", $e->getMessage(), "\n"; } } @@ -27,7 +27,7 @@ int(1) int(2) int(3) int(4) -Cannot modify readonly property Test::$default -Cannot modify readonly property Test::$private -Cannot modify readonly property Test::$protected -Cannot modify readonly property Test::$public +Error: Cannot modify readonly property Test::$default +Error: Cannot modify readonly property Test::$private +Error: Cannot modify readonly property Test::$protected +Error: Cannot modify readonly property Test::$public diff --git a/Zend/tests/readonly_props/readonly_with_default_reflection.phpt b/Zend/tests/readonly_props/readonly_with_default_reflection.phpt index fd1df1ebff29..b10191b202be 100644 --- a/Zend/tests/readonly_props/readonly_with_default_reflection.phpt +++ b/Zend/tests/readonly_props/readonly_with_default_reflection.phpt @@ -18,7 +18,7 @@ $test = new Foo(); try { $rp->setValue($test, 2); } catch (Error $e) { - echo $e->getMessage(), "\n"; + echo $e::class, ": ", $e->getMessage(), "\n"; } ?> @@ -32,4 +32,4 @@ array(2) { ["nullable"]=> NULL } -Cannot modify readonly property Foo::$prop +Error: Cannot modify readonly property Foo::$prop diff --git a/Zend/tests/readonly_props/unset.phpt b/Zend/tests/readonly_props/unset.phpt index ee86881d288c..f276ecaf2148 100644 --- a/Zend/tests/readonly_props/unset.phpt +++ b/Zend/tests/readonly_props/unset.phpt @@ -61,7 +61,7 @@ class Test4 { try { unset($this->prop); } catch (Error $e) { - echo $e->getMessage(), "\n"; + echo $e::class, ": ", $e->getMessage(), "\n"; } } @@ -77,7 +77,7 @@ var_dump($test->prop); // Don't call __get. try { unset($test->prop); } catch (Error $e) { - echo $e->getMessage(), "\n"; + echo $e::class, ": ", $e->getMessage(), "\n"; } var_dump($test->prop); // Still don't call __get. @@ -89,7 +89,7 @@ int(1) int(1) Cannot unset readonly property Test2::$prop Cannot unset protected(set) readonly property Test3::$prop from global scope -Cannot unset readonly property Test4::$prop +Error: Cannot unset readonly property Test4::$prop int(1) -Cannot unset readonly property Test4::$prop +Error: Cannot unset readonly property Test4::$prop int(1) From c530ea7404d92dbc5ec241338db50d71dbfa3b3c Mon Sep 17 00:00:00 2001 From: NickSdot Date: Sun, 5 Jul 2026 11:40:01 +0700 Subject: [PATCH 3/7] Improved tests according to review feedback --- ...adonly_with_default_interface_get_only.phpt} | 14 +------------- ...readonly_with_default_interface_get_set.phpt | 16 ++++++++++++++++ ..._default_interface_get_set_implicit_set.phpt | 17 +++++++++++++++++ 3 files changed, 34 insertions(+), 13 deletions(-) rename Zend/tests/readonly_props/{readonly_with_default_interface.phpt => readonly_with_default_interface_get_only.phpt} (51%) create mode 100644 Zend/tests/readonly_props/readonly_with_default_interface_get_set.phpt create mode 100644 Zend/tests/readonly_props/readonly_with_default_interface_get_set_implicit_set.phpt diff --git a/Zend/tests/readonly_props/readonly_with_default_interface.phpt b/Zend/tests/readonly_props/readonly_with_default_interface_get_only.phpt similarity index 51% rename from Zend/tests/readonly_props/readonly_with_default_interface.phpt rename to Zend/tests/readonly_props/readonly_with_default_interface_get_only.phpt index a20d869dc661..2a17204255fe 100644 --- a/Zend/tests/readonly_props/readonly_with_default_interface.phpt +++ b/Zend/tests/readonly_props/readonly_with_default_interface_get_only.phpt @@ -12,18 +12,6 @@ class C implements I { } var_dump(new C()->prop); - -interface J { - public int $prop { get; set; } -} - -// does not satisfy set -class D implements J { - public readonly int $prop = 42; -} - ?> --EXPECTF-- -int(42) - -Fatal error: Set access level of D::$prop must be omitted (as in class J) in %s on line %d +int(42) \ No newline at end of file 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 000000000000..102444cc044f --- /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-- + +--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 000000000000..106bf2aeb0cf --- /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-- + +--EXPECTF-- +Fatal error: Set access level of C::$prop must be omitted (as in class I) in %s on line %d From 5f637da2c21cbab0576f06e11fd95e72e2ecfeb8 Mon Sep 17 00:00:00 2001 From: NickSdot Date: Sun, 5 Jul 2026 13:09:02 +0700 Subject: [PATCH 4/7] Added abstract class test The engine has a non-optimal error here. Will be addressed in a separate PR. Ref: https://github.com/php/php-src/pull/22588#discussion_r3523422058 --- ..._default_abstract_get_set_implicit_set.phpt | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 Zend/tests/readonly_props/readonly_with_default_abstract_get_set_implicit_set.phpt 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 000000000000..e61574d07aaf --- /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-- + +--EXPECTF-- +Fatal error: Set access level of C::$prop must be omitted (as in class P) in %s on line %d From 61d773b53d4ad66444eae9739641856337a3e667 Mon Sep 17 00:00:00 2001 From: NickSdot Date: Thu, 9 Jul 2026 17:29:55 +0700 Subject: [PATCH 5/7] Added clone-with test --- .../readonly_clone_success1.phpt | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/Zend/tests/readonly_props/readonly_clone_success1.phpt b/Zend/tests/readonly_props/readonly_clone_success1.phpt index ab6ee3157332..50e5e6df81e2 100644 --- a/Zend/tests/readonly_props/readonly_clone_success1.phpt +++ b/Zend/tests/readonly_props/readonly_clone_success1.phpt @@ -41,6 +41,23 @@ 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) { @@ -67,3 +84,15 @@ 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) +} From 9b650f4e2d7184e2e12ec9dbe145eac0107e0fc9 Mon Sep 17 00:00:00 2001 From: NickSdot Date: Thu, 9 Jul 2026 18:51:52 +0700 Subject: [PATCH 6/7] Added test for __unserialize --- Zend/tests/readonly_props/serialization.phpt | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/Zend/tests/readonly_props/serialization.phpt b/Zend/tests/readonly_props/serialization.phpt index f51fce199a63..7febafd7dca2 100644 --- a/Zend/tests/readonly_props/serialization.phpt +++ b/Zend/tests/readonly_props/serialization.phpt @@ -29,6 +29,22 @@ var_dump(unserialize("O:11:\"TestDefault\":0:{}")); var_dump(unserialize("O:11:\"TestDefault\":1:{s:4:\"prop\";i:2;}")); var_dump(unserialize("O:11:\"TestDefault\":2:{s:4:\"prop\";i:2;s:4:\"prop\";i:3;}")); +class TestDefaultWithUnserialize { + public readonly int $prop = 1; + + public function __unserialize(array $data): void { + foreach ($data as $key => $value) { + $this->{$key} = $value; + } + } +} + +try { + var_dump(unserialize("O:26:\"TestDefaultWithUnserialize\":1:{s:4:\"prop\";i:2;}")); +} catch (Error $e) { + echo $e::class, ": ", $e->getMessage(), "\n"; +} + ?> --EXPECT-- string(30) "O:4:"Test":1:{s:4:"prop";i:1;}" @@ -61,3 +77,4 @@ object(TestDefault)#1 (1) { ["prop"]=> int(3) } +Error: Cannot modify readonly property TestDefaultWithUnserialize::$prop From 693761022138c4062ec0aee9c88d418cbd5a0e92 Mon Sep 17 00:00:00 2001 From: NickSdot Date: Fri, 10 Jul 2026 00:44:33 +0700 Subject: [PATCH 7/7] Allowed __unserialize to unserialise into properties with default values The helper currently has one user path with two calls, and is placed in Zend because the same operation is needed by clone/clone-with, which could make it 5 callers; ref: https://github.com/php/php-src/pull/22654 --- Zend/tests/readonly_props/serialization.phpt | 10 ++++++++-- Zend/zend_objects.c | 18 ++++++++++++++++++ Zend/zend_objects.h | 1 + ext/standard/var_unserializer.re | 3 +++ 4 files changed, 30 insertions(+), 2 deletions(-) diff --git a/Zend/tests/readonly_props/serialization.phpt b/Zend/tests/readonly_props/serialization.phpt index 7febafd7dca2..0667f6d76f61 100644 --- a/Zend/tests/readonly_props/serialization.phpt +++ b/Zend/tests/readonly_props/serialization.phpt @@ -24,7 +24,6 @@ class TestDefault { var_dump($s = serialize(new TestDefault)); var_dump(unserialize($s)); -// Unserialization still bypasses normal readonly assignment semantics. var_dump(unserialize("O:11:\"TestDefault\":0:{}")); var_dump(unserialize("O:11:\"TestDefault\":1:{s:4:\"prop\";i:2;}")); var_dump(unserialize("O:11:\"TestDefault\":2:{s:4:\"prop\";i:2;s:4:\"prop\";i:3;}")); @@ -39,8 +38,11 @@ class TestDefaultWithUnserialize { } } +$testDefaultWithUnserialize = unserialize("O:26:\"TestDefaultWithUnserialize\":1:{s:4:\"prop\";i:2;}"); +var_dump($testDefaultWithUnserialize); + try { - var_dump(unserialize("O:26:\"TestDefaultWithUnserialize\":1:{s:4:\"prop\";i:2;}")); + $testDefaultWithUnserialize->prop = 3; } catch (Error $e) { echo $e::class, ": ", $e->getMessage(), "\n"; } @@ -77,4 +79,8 @@ object(TestDefault)#1 (1) { ["prop"]=> int(3) } +object(TestDefaultWithUnserialize)#1 (1) { + ["prop"]=> + int(2) +} Error: Cannot modify readonly property TestDefaultWithUnserialize::$prop diff --git a/Zend/zend_objects.c b/Zend/zend_objects.c index 2fc264742cd1..938a81c6bdb0 100644 --- a/Zend/zend_objects.c +++ b/Zend/zend_objects.c @@ -193,6 +193,24 @@ ZEND_API zend_object* ZEND_FASTCALL zend_objects_new(zend_class_entry *ce) return object; } +ZEND_API void ZEND_FASTCALL zend_object_set_properties_reinitable(zend_object *object, bool reinitable) +{ + if (!ZEND_CLASS_HAS_READONLY_PROPS(object->ce)) { + return; + } + + for (uint32_t i = 0; i < object->ce->default_properties_count; i++) { + zval *prop = OBJ_PROP_NUM(object, i); + if (reinitable) { + if (!Z_ISUNDEF_P(prop)) { + Z_PROP_FLAG_P(prop) |= IS_PROP_REINITABLE; + } + } else { + Z_PROP_FLAG_P(prop) &= ~IS_PROP_REINITABLE; + } + } +} + ZEND_API void ZEND_FASTCALL zend_objects_clone_members(zend_object *new_object, const zend_object *old_object) { bool has_clone_method = old_object->ce->clone != NULL; diff --git a/Zend/zend_objects.h b/Zend/zend_objects.h index 0930fa043101..8bb6c053b0c7 100644 --- a/Zend/zend_objects.h +++ b/Zend/zend_objects.h @@ -25,6 +25,7 @@ BEGIN_EXTERN_C() ZEND_API void ZEND_FASTCALL zend_object_std_init(zend_object *object, zend_class_entry *ce); ZEND_API zend_object* ZEND_FASTCALL zend_objects_new(zend_class_entry *ce); ZEND_API void ZEND_FASTCALL zend_objects_clone_members(zend_object *new_object, const zend_object *old_object); +ZEND_API void ZEND_FASTCALL zend_object_set_properties_reinitable(zend_object *object, bool reinitable); ZEND_API void zend_object_std_dtor(zend_object *object); ZEND_API void zend_objects_destroy_object(zend_object *object); diff --git a/ext/standard/var_unserializer.re b/ext/standard/var_unserializer.re index 4a9b278c116c..5e404cd60e2d 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*) */ @@ -301,8 +302,10 @@ PHPAPI void var_destroy(php_unserialize_data_t *var_hashx) ZVAL_COPY(¶m, &var_dtor_hash->data[i + 1]); BG(serialize_lock)++; + zend_object_set_properties_reinitable(Z_OBJ_P(zv), /* reinitable */ true); zend_call_known_instance_method_with_1_params( Z_OBJCE_P(zv)->__unserialize, Z_OBJ_P(zv), NULL, ¶m); + zend_object_set_properties_reinitable(Z_OBJ_P(zv), /* reinitable */ false); if (EG(exception)) { delayed_call_failed = 1; GC_ADD_FLAGS(Z_OBJ_P(zv), IS_OBJ_DESTRUCTOR_CALLED);