Commit e4f727d61eb for php.net
commit e4f727d61eb0ad04d158ef0f67cdabbc7427d6ac
Author: Ilija Tovilo <ilija.tovilo@me.com>
Date: Fri Oct 4 00:59:24 2024 +0200
Implement ReflectionProperty::is{Readable,Writable}()
RFC: https://wiki.php.net/rfc/isreadable-iswriteable
Fixes GH-15309
Fixes GH-16175
Closes GH-16209
diff --git a/UPGRADING b/UPGRADING
index 9a1b37d98f7..151b27b2267 100644
--- a/UPGRADING
+++ b/UPGRADING
@@ -134,6 +134,8 @@ PHP 8.6 UPGRADE NOTES
- Reflection:
. ReflectionConstant::inNamespace()
+ . ReflectionProperty::isReadable() ReflectionProperty::isWritable() were
+ added.
- Standard:
. `clamp()` returns the given value if in range, else return the nearest
diff --git a/ext/reflection/php_reflection.c b/ext/reflection/php_reflection.c
index 232c1e6b36a..2a9b4776350 100644
--- a/ext/reflection/php_reflection.c
+++ b/ext/reflection/php_reflection.c
@@ -6601,6 +6601,242 @@ ZEND_METHOD(ReflectionProperty, isFinal)
_property_check_flag(INTERNAL_FUNCTION_PARAM_PASSTHRU, ZEND_ACC_FINAL);
}
+static zend_result get_ce_from_scope_name(zend_class_entry **scope, zend_string *scope_name, zend_execute_data *execute_data)
+{
+ if (!scope_name) {
+ *scope = NULL;
+ return SUCCESS;
+ }
+
+ *scope = zend_lookup_class(scope_name);
+ if (!*scope) {
+ zend_throw_error(NULL, "Class \"%s\" not found", ZSTR_VAL(scope_name));
+ return FAILURE;
+ }
+ return SUCCESS;
+}
+
+static zend_always_inline uint32_t set_visibility_to_visibility(uint32_t set_visibility)
+{
+ switch (set_visibility) {
+ case ZEND_ACC_PUBLIC_SET:
+ return ZEND_ACC_PUBLIC;
+ case ZEND_ACC_PROTECTED_SET:
+ return ZEND_ACC_PROTECTED;
+ case ZEND_ACC_PRIVATE_SET:
+ return ZEND_ACC_PRIVATE;
+ EMPTY_SWITCH_DEFAULT_CASE();
+ }
+}
+
+static bool check_visibility(uint32_t visibility, zend_class_entry *ce, zend_class_entry *scope)
+{
+ if (!(visibility & ZEND_ACC_PUBLIC) && (scope != ce)) {
+ if (!scope) {
+ return false;
+ }
+ if (visibility & ZEND_ACC_PRIVATE) {
+ return false;
+ }
+ ZEND_ASSERT(visibility & ZEND_ACC_PROTECTED);
+ if (!instanceof_function(scope, ce) && !instanceof_function(ce, scope)) {
+ return false;
+ }
+ }
+ return true;
+}
+
+ZEND_METHOD(ReflectionProperty, isReadable)
+{
+ reflection_object *intern;
+ property_reference *ref;
+ zend_string *scope_name;
+ zend_object *obj = NULL;
+
+ ZEND_PARSE_PARAMETERS_START(1, 2)
+ Z_PARAM_STR_OR_NULL(scope_name)
+ Z_PARAM_OPTIONAL
+ Z_PARAM_OBJ_OR_NULL(obj)
+ ZEND_PARSE_PARAMETERS_END();
+
+ GET_REFLECTION_OBJECT_PTR(ref);
+
+ zend_property_info *prop = ref->prop;
+ if (prop && obj) {
+ if (prop->flags & ZEND_ACC_STATIC) {
+ _DO_THROW("null is expected as object argument for static properties");
+ RETURN_THROWS();
+ }
+ if (!instanceof_function(obj->ce, prop->ce)) {
+ _DO_THROW("Given object is not an instance of the class this property was declared in");
+ RETURN_THROWS();
+ }
+ prop = reflection_property_get_effective_prop(ref, intern->ce, obj);
+ }
+
+ zend_class_entry *ce = obj ? obj->ce : intern->ce;
+ if (!prop) {
+ if (obj && obj->properties && zend_hash_find_ptr(obj->properties, ref->unmangled_name)) {
+ RETURN_TRUE;
+ }
+handle_magic_get:
+ if (ce->__get) {
+ if (obj && ce->__isset) {
+ uint32_t *guard = zend_get_property_guard(obj, ref->unmangled_name);
+ if (!((*guard) & ZEND_GUARD_PROPERTY_ISSET)) {
+ GC_ADDREF(obj);
+ *guard |= ZEND_GUARD_PROPERTY_ISSET;
+ zval member;
+ ZVAL_STR(&member, ref->unmangled_name);
+ zend_call_known_instance_method_with_1_params(ce->__isset, obj, return_value, &member);
+ *guard &= ~ZEND_GUARD_PROPERTY_ISSET;
+ OBJ_RELEASE(obj);
+ return;
+ }
+ }
+ RETURN_TRUE;
+ }
+ if (obj && zend_lazy_object_must_init(obj)) {
+ obj = zend_lazy_object_init(obj);
+ if (!obj) {
+ RETURN_THROWS();
+ }
+ if (obj->properties && zend_hash_find_ptr(obj->properties, ref->unmangled_name)) {
+ RETURN_TRUE;
+ }
+ }
+ RETURN_FALSE;
+ }
+
+ zend_class_entry *scope;
+ if (get_ce_from_scope_name(&scope, scope_name, execute_data) == FAILURE) {
+ RETURN_THROWS();
+ }
+
+ if (!check_visibility(prop->flags & ZEND_ACC_PPP_MASK, prop->ce, scope)) {
+ if (!(prop->flags & ZEND_ACC_STATIC)) {
+ goto handle_magic_get;
+ }
+ RETURN_FALSE;
+ }
+
+ if (prop->flags & ZEND_ACC_VIRTUAL) {
+ ZEND_ASSERT(prop->hooks);
+ if (!prop->hooks[ZEND_PROPERTY_HOOK_GET]) {
+ RETURN_FALSE;
+ }
+ } else if (obj && (!prop->hooks || !prop->hooks[ZEND_PROPERTY_HOOK_GET])) {
+retry_declared:;
+ zval *prop_val = OBJ_PROP(obj, prop->offset);
+ if (Z_TYPE_P(prop_val) == IS_UNDEF) {
+ if (zend_lazy_object_must_init(obj) && (Z_PROP_FLAG_P(prop_val) & IS_PROP_LAZY)) {
+ obj = zend_lazy_object_init(obj);
+ if (!obj) {
+ RETURN_THROWS();
+ }
+ goto retry_declared;
+ }
+ if (!(Z_PROP_FLAG_P(prop_val) & IS_PROP_UNINIT)) {
+ goto handle_magic_get;
+ }
+ RETURN_FALSE;
+ }
+ } else if (prop->flags & ZEND_ACC_STATIC) {
+ if (ce->default_static_members_count && !CE_STATIC_MEMBERS(ce)) {
+ zend_class_init_statics(ce);
+ }
+ zval *prop_val = CE_STATIC_MEMBERS(ce) + prop->offset;
+ RETURN_BOOL(!Z_ISUNDEF_P(prop_val));
+ }
+
+ RETURN_TRUE;
+}
+
+ZEND_METHOD(ReflectionProperty, isWritable)
+{
+ reflection_object *intern;
+ property_reference *ref;
+ zend_string *scope_name;
+ zend_object *obj = NULL;
+
+ ZEND_PARSE_PARAMETERS_START(1, 2)
+ Z_PARAM_STR_OR_NULL(scope_name)
+ Z_PARAM_OPTIONAL
+ Z_PARAM_OBJ_OR_NULL(obj)
+ ZEND_PARSE_PARAMETERS_END();
+
+ GET_REFLECTION_OBJECT_PTR(ref);
+
+ zend_property_info *prop = ref->prop;
+ if (prop && obj) {
+ if (prop->flags & ZEND_ACC_STATIC) {
+ _DO_THROW("null is expected as object argument for static properties");
+ RETURN_THROWS();
+ }
+ if (!instanceof_function(obj->ce, prop->ce)) {
+ _DO_THROW("Given object is not an instance of the class this property was declared in");
+ RETURN_THROWS();
+ }
+ prop = reflection_property_get_effective_prop(ref, intern->ce, obj);
+ }
+
+ zend_class_entry *ce = obj ? obj->ce : intern->ce;
+ if (!prop) {
+ if (!(ce->ce_flags & ZEND_ACC_NO_DYNAMIC_PROPERTIES)) {
+ RETURN_TRUE;
+ }
+ /* This path is effectively unreachable, but theoretically possible for
+ * two internal classes where ZEND_ACC_NO_DYNAMIC_PROPERTIES is only
+ * added to the subclass, in which case a ReflectionProperty can be
+ * constructed on the parent class, and then tested on the subclass. */
+handle_magic_set:
+ RETURN_BOOL(ce->__set);
+ }
+
+ zend_class_entry *scope;
+ if (get_ce_from_scope_name(&scope, scope_name, execute_data) == FAILURE) {
+ RETURN_THROWS();
+ }
+
+ if (!check_visibility(prop->flags & ZEND_ACC_PPP_MASK, prop->ce, scope)) {
+ if (!(prop->flags & ZEND_ACC_STATIC)) {
+ goto handle_magic_set;
+ }
+ RETURN_FALSE;
+ }
+ uint32_t set_visibility = prop->flags & ZEND_ACC_PPP_SET_MASK;
+ if (!set_visibility) {
+ set_visibility = zend_visibility_to_set_visibility(prop->flags & ZEND_ACC_PPP_MASK);
+ }
+ if (!check_visibility(set_visibility_to_visibility(set_visibility), prop->ce, scope)) {
+ RETURN_FALSE;
+ }
+
+ if (prop->flags & ZEND_ACC_VIRTUAL) {
+ ZEND_ASSERT(prop->hooks);
+ if (!prop->hooks[ZEND_PROPERTY_HOOK_SET]) {
+ RETURN_FALSE;
+ }
+ } else if (obj && (prop->flags & ZEND_ACC_READONLY)) {
+retry:;
+ zval *prop_val = OBJ_PROP(obj, prop->offset);
+ if (Z_TYPE_P(prop_val) == IS_UNDEF
+ && zend_lazy_object_must_init(obj)
+ && (Z_PROP_FLAG_P(prop_val) & IS_PROP_LAZY)) {
+ obj = zend_lazy_object_init(obj);
+ if (!obj) {
+ RETURN_THROWS();
+ }
+ goto retry;
+ }
+ if (Z_TYPE_P(prop_val) != IS_UNDEF && !(Z_PROP_FLAG_P(prop_val) & IS_PROP_REINITABLE)) {
+ RETURN_FALSE;
+ }
+ }
+
+ RETURN_TRUE;
+}
+
/* {{{ Constructor. Throws an Exception in case the given extension does not exist */
ZEND_METHOD(ReflectionExtension, __construct)
{
diff --git a/ext/reflection/php_reflection.stub.php b/ext/reflection/php_reflection.stub.php
index 8f2c49460b4..b0273a3174f 100644
--- a/ext/reflection/php_reflection.stub.php
+++ b/ext/reflection/php_reflection.stub.php
@@ -574,6 +574,10 @@ public function hasHook(PropertyHookType $type): bool {}
public function getHook(PropertyHookType $type): ?ReflectionMethod {}
public function isFinal(): bool {}
+
+ public function isReadable(?string $scope, ?object $object = null): bool {}
+
+ public function isWritable(?string $scope, ?object $object = null): bool {}
}
/** @not-serializable */
diff --git a/ext/reflection/php_reflection_arginfo.h b/ext/reflection/php_reflection_arginfo.h
index e16ea82b0e9..66605a22bbd 100644
Binary files a/ext/reflection/php_reflection_arginfo.h and b/ext/reflection/php_reflection_arginfo.h differ
diff --git a/ext/reflection/php_reflection_decl.h b/ext/reflection/php_reflection_decl.h
index f93c1d0c887..a5e8affd0be 100644
Binary files a/ext/reflection/php_reflection_decl.h and b/ext/reflection/php_reflection_decl.h differ
diff --git a/ext/reflection/tests/ReflectionProperty_isReadable_dynamic.phpt b/ext/reflection/tests/ReflectionProperty_isReadable_dynamic.phpt
new file mode 100644
index 00000000000..a4056a60555
--- /dev/null
+++ b/ext/reflection/tests/ReflectionProperty_isReadable_dynamic.phpt
@@ -0,0 +1,28 @@
+--TEST--
+Test ReflectionProperty::isReadable() dynamic
+--FILE--
+<?php
+
+#[AllowDynamicProperties]
+class A {}
+
+$a = new A;
+
+$a->a = 'a';
+$r = new ReflectionProperty($a, 'a');
+
+var_dump($r->isReadable(null, $a));
+unset($a->a);
+var_dump($r->isReadable(null, $a));
+
+$a = new A;
+var_dump($r->isReadable(null, $a));
+
+var_dump($r->isReadable(null, null));
+
+?>
+--EXPECT--
+bool(true)
+bool(false)
+bool(false)
+bool(false)
diff --git a/ext/reflection/tests/ReflectionProperty_isReadable_hooks.phpt b/ext/reflection/tests/ReflectionProperty_isReadable_hooks.phpt
new file mode 100644
index 00000000000..5fc31b2163e
--- /dev/null
+++ b/ext/reflection/tests/ReflectionProperty_isReadable_hooks.phpt
@@ -0,0 +1,39 @@
+--TEST--
+Test ReflectionProperty::isReadable() hooks
+--FILE--
+<?php
+
+class A {
+ public $a { get => $this->a; }
+ public $b { get => 42; }
+ public $c { set => $value; }
+ public $d { set {} }
+ public $e { get => $this->e; set => $value; }
+ public $f { get {} set {} }
+}
+
+function test($scope) {
+ $rc = new ReflectionClass(A::class);
+ foreach ($rc->getProperties() as $rp) {
+ echo $rp->getName() . ' from ' . ($scope ?? 'global') . ': ';
+ var_dump($rp->isReadable($scope, null));
+ }
+}
+
+test('A');
+test(null);
+
+?>
+--EXPECT--
+a from A: bool(true)
+b from A: bool(true)
+c from A: bool(true)
+d from A: bool(false)
+e from A: bool(true)
+f from A: bool(true)
+a from global: bool(true)
+b from global: bool(true)
+c from global: bool(true)
+d from global: bool(false)
+e from global: bool(true)
+f from global: bool(true)
diff --git a/ext/reflection/tests/ReflectionProperty_isReadable_init.phpt b/ext/reflection/tests/ReflectionProperty_isReadable_init.phpt
new file mode 100644
index 00000000000..242f28b57a8
--- /dev/null
+++ b/ext/reflection/tests/ReflectionProperty_isReadable_init.phpt
@@ -0,0 +1,72 @@
+--TEST--
+Test ReflectionProperty::isReadable() init
+--FILE--
+<?php
+
+class A {
+ public $a;
+ public int $b;
+ public int $c = 42;
+ public int $d;
+ public int $e;
+
+ public function __construct() {
+ unset($this->e);
+ }
+}
+
+class B {
+ public int $f;
+ public int $g;
+ public int $h;
+
+ public function __construct() {
+ unset($this->g);
+ unset($this->h);
+ }
+
+ public function __isset($name) {
+ return $name === 'h';
+ }
+
+ public function __get($name) {}
+}
+
+class C {
+ public int $i;
+ public int $j;
+ public int $k;
+
+ public function __construct() {
+ unset($this->j);
+ unset($this->k);
+ }
+
+ public function __get($name) {}
+}
+
+function test($class) {
+ $rc = new ReflectionClass($class);
+ foreach ($rc->getProperties() as $rp) {
+ echo $rp->getName() . ' from global: ';
+ var_dump($rp->isReadable(null, new $class));
+ }
+}
+
+test('A');
+test('B');
+test('C');
+
+?>
+--EXPECT--
+a from global: bool(true)
+b from global: bool(false)
+c from global: bool(true)
+d from global: bool(false)
+e from global: bool(false)
+f from global: bool(false)
+g from global: bool(false)
+h from global: bool(true)
+i from global: bool(false)
+j from global: bool(true)
+k from global: bool(true)
diff --git a/ext/reflection/tests/ReflectionProperty_isReadable_invalid_scope.phpt b/ext/reflection/tests/ReflectionProperty_isReadable_invalid_scope.phpt
new file mode 100644
index 00000000000..ae9f446c677
--- /dev/null
+++ b/ext/reflection/tests/ReflectionProperty_isReadable_invalid_scope.phpt
@@ -0,0 +1,20 @@
+--TEST--
+Test ReflectionProperty::isReadable() invalid scope
+--FILE--
+<?php
+
+class A {
+ public $prop;
+}
+
+$r = new ReflectionProperty('A', 'prop');
+
+try {
+ $r->isReadable('B', null);
+} catch (Error $e) {
+ echo $e::class, ': ', $e->getMessage(), "\n";
+}
+
+?>
+--EXPECT--
+Error: Class "B" not found
diff --git a/ext/reflection/tests/ReflectionProperty_isReadable_lazy.phpt b/ext/reflection/tests/ReflectionProperty_isReadable_lazy.phpt
new file mode 100644
index 00000000000..214708cda6a
--- /dev/null
+++ b/ext/reflection/tests/ReflectionProperty_isReadable_lazy.phpt
@@ -0,0 +1,29 @@
+--TEST--
+Test ReflectionProperty::isReadable() lazy
+--CREDITS--
+Arnaud Le Blanc (arnaud-lb)
+--FILE--
+<?php
+
+class A {
+ public int $a;
+ public int $b;
+
+ public function __construct() {
+ $this->a = 1;
+ }
+}
+
+$rc = new ReflectionClass(A::class);
+$obj = $rc->newLazyProxy(fn() => new A());
+
+$rp = new ReflectionProperty(A::class, 'a');
+var_dump($rp->isReadable(null, $obj));
+
+$rp = new ReflectionProperty(A::class, 'b');
+var_dump($rp->isReadable(null, $obj));
+
+?>
+--EXPECT--
+bool(true)
+bool(false)
diff --git a/ext/reflection/tests/ReflectionProperty_isReadable_lazy_dynamic.phpt b/ext/reflection/tests/ReflectionProperty_isReadable_lazy_dynamic.phpt
new file mode 100644
index 00000000000..a40b32f0abd
--- /dev/null
+++ b/ext/reflection/tests/ReflectionProperty_isReadable_lazy_dynamic.phpt
@@ -0,0 +1,25 @@
+--TEST--
+Test ReflectionProperty::isReadable() lazy dynamic
+--CREDITS--
+Arnaud Le Blanc (arnaud-lb)
+--FILE--
+<?php
+
+#[AllowDynamicProperties]
+class A {
+ public $_;
+
+ public function __construct() {
+ $this->prop = 1;
+ }
+}
+
+$rc = new ReflectionClass(A::class);
+$obj = $rc->newLazyProxy(fn() => new A());
+
+$rp = new ReflectionProperty(new A, 'prop');
+var_dump($rp->isReadable(null, $obj));
+
+?>
+--EXPECT--
+bool(true)
diff --git a/ext/reflection/tests/ReflectionProperty_isReadable_lazy_isset.phpt b/ext/reflection/tests/ReflectionProperty_isReadable_lazy_isset.phpt
new file mode 100644
index 00000000000..398c3311e49
--- /dev/null
+++ b/ext/reflection/tests/ReflectionProperty_isReadable_lazy_isset.phpt
@@ -0,0 +1,33 @@
+--TEST--
+Test ReflectionProperty::isReadable() lazy isset
+--CREDITS--
+Arnaud Le Blanc (arnaud-lb)
+--FILE--
+<?php
+
+#[AllowDynamicProperties]
+class A {
+ public $_;
+
+ public function __construct() {
+ $this->prop = 1;
+ }
+
+ public function __isset($name) {
+ return false;
+ }
+
+ public function __get($name) {
+ return null;
+ }
+}
+
+$rc = new ReflectionClass(A::class);
+$obj = $rc->newLazyProxy(fn() => new A);
+
+$rp = new ReflectionProperty(new A, 'prop');
+var_dump($rp->isReadable(null, $obj));
+
+?>
+--EXPECT--
+bool(false)
diff --git a/ext/reflection/tests/ReflectionProperty_isReadable_static.phpt b/ext/reflection/tests/ReflectionProperty_isReadable_static.phpt
new file mode 100644
index 00000000000..e927f428a1f
--- /dev/null
+++ b/ext/reflection/tests/ReflectionProperty_isReadable_static.phpt
@@ -0,0 +1,36 @@
+--TEST--
+Test ReflectionProperty::isReadable() static
+--FILE--
+<?php
+
+class A {
+ public static $a;
+ public static int $b;
+ public static int $c = 42;
+ protected static int $d = 42;
+ private static int $e = 42;
+ public private(set) static int $f = 42;
+}
+
+$r = new ReflectionProperty('A', 'a');
+try {
+ $r->isReadable(null, new A);
+} catch (Exception $e) {
+ echo $e::class, ': ', $e->getMessage(), "\n";
+}
+
+$rc = new ReflectionClass('A');
+foreach ($rc->getProperties() as $rp) {
+ echo $rp->getName() . ' from global: ';
+ var_dump($rp->isReadable(null));
+}
+
+?>
+--EXPECT--
+ReflectionException: null is expected as object argument for static properties
+a from global: bool(true)
+b from global: bool(false)
+c from global: bool(true)
+d from global: bool(false)
+e from global: bool(false)
+f from global: bool(true)
diff --git a/ext/reflection/tests/ReflectionProperty_isReadable_unrelated_object.phpt b/ext/reflection/tests/ReflectionProperty_isReadable_unrelated_object.phpt
new file mode 100644
index 00000000000..ce48958c28c
--- /dev/null
+++ b/ext/reflection/tests/ReflectionProperty_isReadable_unrelated_object.phpt
@@ -0,0 +1,36 @@
+--TEST--
+Test ReflectionProperty::isReadable() unrelated object
+--FILE--
+<?php
+
+class A {
+}
+
+class B extends A {
+ public $prop;
+}
+
+class C extends B {}
+
+class D {}
+
+function test($obj) {
+ $r = new ReflectionProperty('B', 'prop');
+ try {
+ var_dump($r->isReadable(null, $obj));
+ } catch (Exception $e) {
+ echo $e::class, ': ', $e->getMessage(), "\n";
+ }
+}
+
+test(new A);
+test(new B);
+test(new C);
+test(new D);
+
+?>
+--EXPECT--
+ReflectionException: Given object is not an instance of the class this property was declared in
+bool(true)
+bool(true)
+ReflectionException: Given object is not an instance of the class this property was declared in
diff --git a/ext/reflection/tests/ReflectionProperty_isReadable_visibility.phpt b/ext/reflection/tests/ReflectionProperty_isReadable_visibility.phpt
new file mode 100644
index 00000000000..205ff85293f
--- /dev/null
+++ b/ext/reflection/tests/ReflectionProperty_isReadable_visibility.phpt
@@ -0,0 +1,67 @@
+--TEST--
+Test ReflectionProperty::isReadable() visibility
+--FILE--
+<?php
+
+class A {}
+
+class B extends A {
+ public $a;
+ protected $b;
+ private $c;
+ public protected(set) int $d = 42;
+}
+
+class C extends B {}
+
+class D extends C {
+ public function __get($name) {}
+}
+
+class E extends D {
+ private $f;
+
+ public function __isset($name) {
+ return $name === 'f';
+ }
+}
+
+function test($scope) {
+ $rc = new ReflectionClass(B::class);
+ foreach ($rc->getProperties() as $rp) {
+ echo $rp->getName() . ' from ' . ($scope ?? 'global') . ': ';
+ var_dump($rp->isReadable($scope, $scope && $scope !== 'A' ? new $scope : null));
+ }
+}
+
+foreach (['A', 'B', 'C', 'D', 'E'] as $scope) {
+ test($scope);
+}
+test(null);
+
+?>
+--EXPECT--
+a from A: bool(true)
+b from A: bool(true)
+c from A: bool(false)
+d from A: bool(true)
+a from B: bool(true)
+b from B: bool(true)
+c from B: bool(true)
+d from B: bool(true)
+a from C: bool(true)
+b from C: bool(true)
+c from C: bool(false)
+d from C: bool(true)
+a from D: bool(true)
+b from D: bool(true)
+c from D: bool(true)
+d from D: bool(true)
+a from E: bool(true)
+b from E: bool(true)
+c from E: bool(false)
+d from E: bool(true)
+a from global: bool(true)
+b from global: bool(false)
+c from global: bool(false)
+d from global: bool(true)
diff --git a/ext/reflection/tests/ReflectionProperty_isWritable_dynamic.phpt b/ext/reflection/tests/ReflectionProperty_isWritable_dynamic.phpt
new file mode 100644
index 00000000000..e9da6675dda
--- /dev/null
+++ b/ext/reflection/tests/ReflectionProperty_isWritable_dynamic.phpt
@@ -0,0 +1,32 @@
+--TEST--
+Test ReflectionProperty::isWritable() dynamic
+--FILE--
+<?php
+
+#[AllowDynamicProperties]
+class A {
+ private $a;
+}
+
+$a = new A;
+
+$r = new ReflectionProperty($a, 'a');
+var_dump($r->isWritable(null, $a));
+var_dump($r->isWritable(null, null));
+
+$a->b = 'b';
+$r = new ReflectionProperty($a, 'b');
+var_dump($r->isWritable(null, $a));
+
+$a = new A;
+var_dump($r->isWritable(null, $a));
+
+var_dump($r->isWritable(null, null));
+
+?>
+--EXPECT--
+bool(false)
+bool(false)
+bool(true)
+bool(true)
+bool(true)
diff --git a/ext/reflection/tests/ReflectionProperty_isWritable_hooks.phpt b/ext/reflection/tests/ReflectionProperty_isWritable_hooks.phpt
new file mode 100644
index 00000000000..2617ec65b9e
--- /dev/null
+++ b/ext/reflection/tests/ReflectionProperty_isWritable_hooks.phpt
@@ -0,0 +1,39 @@
+--TEST--
+Test ReflectionProperty::isWritable() visibility
+--FILE--
+<?php
+
+class A {
+ public $a { get => $this->a; }
+ public $b { get => 42; }
+ public $c { set => $value; }
+ public $d { set {} }
+ public $e { get => $this->e; set => $value; }
+ public $f { get {} set {} }
+}
+
+function test($scope) {
+ $rc = new ReflectionClass(A::class);
+ foreach ($rc->getProperties() as $rp) {
+ echo $rp->getName() . ' from ' . ($scope ?? 'global') . ': ';
+ var_dump($rp->isWritable($scope, null));
+ }
+}
+
+test('A');
+test(null);
+
+?>
+--EXPECT--
+a from A: bool(true)
+b from A: bool(false)
+c from A: bool(true)
+d from A: bool(true)
+e from A: bool(true)
+f from A: bool(true)
+a from global: bool(true)
+b from global: bool(false)
+c from global: bool(true)
+d from global: bool(true)
+e from global: bool(true)
+f from global: bool(true)
diff --git a/ext/reflection/tests/ReflectionProperty_isWritable_invalid_scope.phpt b/ext/reflection/tests/ReflectionProperty_isWritable_invalid_scope.phpt
new file mode 100644
index 00000000000..7474e521b35
--- /dev/null
+++ b/ext/reflection/tests/ReflectionProperty_isWritable_invalid_scope.phpt
@@ -0,0 +1,20 @@
+--TEST--
+Test ReflectionProperty::isWritable() invalid scope
+--FILE--
+<?php
+
+class A {
+ public $prop;
+}
+
+$r = new ReflectionProperty('A', 'prop');
+
+try {
+ $r->isWritable('B', null);
+} catch (Error $e) {
+ echo $e::class, ': ', $e->getMessage(), "\n";
+}
+
+?>
+--EXPECT--
+Error: Class "B" not found
diff --git a/ext/reflection/tests/ReflectionProperty_isWritable_lazy.phpt b/ext/reflection/tests/ReflectionProperty_isWritable_lazy.phpt
new file mode 100644
index 00000000000..91f305fef8a
--- /dev/null
+++ b/ext/reflection/tests/ReflectionProperty_isWritable_lazy.phpt
@@ -0,0 +1,29 @@
+--TEST--
+Test ReflectionProperty::isWritable() lazy
+--CREDITS--
+Arnaud Le Blanc (arnaud-lb)
+--FILE--
+<?php
+
+class A {
+ public public(set) readonly int $a;
+ public public(set) readonly int $b;
+
+ public function __construct() {
+ $this->a = 1;
+ }
+}
+
+$rc = new ReflectionClass(A::class);
+$obj = $rc->newLazyProxy(fn() => new A());
+
+$rp = new ReflectionProperty(A::class, 'a');
+var_dump($rp->isWritable(null, $obj));
+
+$rp = new ReflectionProperty(A::class, 'b');
+var_dump($rp->isWritable(null, $obj));
+
+?>
+--EXPECT--
+bool(false)
+bool(true)
diff --git a/ext/reflection/tests/ReflectionProperty_isWritable_readonly.phpt b/ext/reflection/tests/ReflectionProperty_isWritable_readonly.phpt
new file mode 100644
index 00000000000..256f1489590
--- /dev/null
+++ b/ext/reflection/tests/ReflectionProperty_isWritable_readonly.phpt
@@ -0,0 +1,39 @@
+--TEST--
+Test ReflectionProperty::isWritable() readonly
+--FILE--
+<?php
+
+class A {
+ public readonly int $a;
+ public readonly int $b;
+
+ public function __construct() {
+ $this->a = 42;
+ }
+
+ public function __clone() {
+ test($this);
+ $this->a = 43;
+ test($this);
+ }
+}
+
+function test($instance) {
+ $rc = new ReflectionClass($instance);
+ foreach ($rc->getProperties() as $rp) {
+ echo $rp->getName() . ' from A: ';
+ var_dump($rp->isWritable($instance::class, $instance));
+ }
+}
+
+test(new A);
+clone new A;
+
+?>
+--EXPECT--
+a from A: bool(false)
+b from A: bool(true)
+a from A: bool(true)
+b from A: bool(true)
+a from A: bool(false)
+b from A: bool(true)
diff --git a/ext/reflection/tests/ReflectionProperty_isWritable_static.phpt b/ext/reflection/tests/ReflectionProperty_isWritable_static.phpt
new file mode 100644
index 00000000000..008d585c315
--- /dev/null
+++ b/ext/reflection/tests/ReflectionProperty_isWritable_static.phpt
@@ -0,0 +1,36 @@
+--TEST--
+Test ReflectionProperty::isWritable() static
+--FILE--
+<?php
+
+class A {
+ public static $a;
+ public static int $b;
+ public static int $c = 42;
+ protected static int $d = 42;
+ private static int $e = 42;
+ public private(set) static int $f = 42;
+}
+
+$r = new ReflectionProperty('A', 'a');
+try {
+ $r->isWritable(null, new A);
+} catch (Exception $e) {
+ echo $e::class, ': ', $e->getMessage(), "\n";
+}
+
+$rc = new ReflectionClass('A');
+foreach ($rc->getProperties() as $rp) {
+ echo $rp->getName() . ' from global: ';
+ var_dump($rp->isWritable(null));
+}
+
+?>
+--EXPECT--
+ReflectionException: null is expected as object argument for static properties
+a from global: bool(true)
+b from global: bool(true)
+c from global: bool(true)
+d from global: bool(false)
+e from global: bool(false)
+f from global: bool(false)
diff --git a/ext/reflection/tests/ReflectionProperty_isWritable_unrelated_object.phpt b/ext/reflection/tests/ReflectionProperty_isWritable_unrelated_object.phpt
new file mode 100644
index 00000000000..3e1e6a394c8
--- /dev/null
+++ b/ext/reflection/tests/ReflectionProperty_isWritable_unrelated_object.phpt
@@ -0,0 +1,36 @@
+--TEST--
+Test ReflectionProperty::isWritable() unrelated object
+--FILE--
+<?php
+
+class A {
+}
+
+class B extends A {
+ public $prop;
+}
+
+class C extends B {}
+
+class D {}
+
+function test($obj) {
+ $r = new ReflectionProperty('B', 'prop');
+ try {
+ var_dump($r->isWritable(null, $obj));
+ } catch (Exception $e) {
+ echo $e::class, ': ', $e->getMessage(), "\n";
+ }
+}
+
+test(new A);
+test(new B);
+test(new C);
+test(new D);
+
+?>
+--EXPECT--
+ReflectionException: Given object is not an instance of the class this property was declared in
+bool(true)
+bool(true)
+ReflectionException: Given object is not an instance of the class this property was declared in
diff --git a/ext/reflection/tests/ReflectionProperty_isWritable_visibility.phpt b/ext/reflection/tests/ReflectionProperty_isWritable_visibility.phpt
new file mode 100644
index 00000000000..1585f4cba53
--- /dev/null
+++ b/ext/reflection/tests/ReflectionProperty_isWritable_visibility.phpt
@@ -0,0 +1,68 @@
+--TEST--
+Test ReflectionProperty::isWritable() visibility
+--FILE--
+<?php
+
+class A {}
+
+class B extends A {
+ public $a;
+ protected $b;
+ private $c;
+ public private(set) int $d;
+ public protected(set) int $e;
+ public readonly int $f;
+}
+
+class C extends B {}
+
+class D extends C {
+ public function __set($name, $value) {}
+}
+
+function test($scope) {
+ $rc = new ReflectionClass(B::class);
+ foreach ($rc->getProperties() as $rp) {
+ echo $rp->getName() . ' from ' . ($scope ?? 'global') . ': ';
+ var_dump($rp->isWritable($scope, $scope && $scope !== 'A' ? new $scope : null));
+ }
+}
+
+test('A');
+test('B');
+test('C');
+test('D');
+test(null);
+
+?>
+--EXPECT--
+a from A: bool(true)
+b from A: bool(true)
+c from A: bool(false)
+d from A: bool(false)
+e from A: bool(true)
+f from A: bool(true)
+a from B: bool(true)
+b from B: bool(true)
+c from B: bool(true)
+d from B: bool(true)
+e from B: bool(true)
+f from B: bool(true)
+a from C: bool(true)
+b from C: bool(true)
+c from C: bool(false)
+d from C: bool(false)
+e from C: bool(true)
+f from C: bool(true)
+a from D: bool(true)
+b from D: bool(true)
+c from D: bool(true)
+d from D: bool(false)
+e from D: bool(true)
+f from D: bool(true)
+a from global: bool(true)
+b from global: bool(false)
+c from global: bool(false)
+d from global: bool(false)
+e from global: bool(false)
+f from global: bool(false)