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)