Commit 9c082438f4c for php.net

commit 9c082438f4cf922e0d56319ebf404314a074107b
Author: Ilia Alshanetsky <ilia@ilia.ws>
Date:   Tue Mar 31 13:10:41 2026 -0400

    Fix GH-21478: Forward property operations to real instance for initialized lazy proxies

    Closes GH-21480

diff --git a/NEWS b/NEWS
index 2472825c0da..c9933ff6b26 100644
--- a/NEWS
+++ b/NEWS
@@ -5,6 +5,8 @@ PHP                                                                        NEWS
 - Core:
   . Fixed bug GH-19983 (GC assertion failure with fibers, generators and
     destructors). (iliaal)
+  . Fixed bug GH-21478 (Forward property operations to real instance for
+    initialized lazy proxies). (iliaal)

 - DOM:
   . Fixed bug GH-21566 (Dom\XMLDocument::C14N() emits duplicate xmlns
diff --git a/Zend/tests/lazy_objects/gh18038-002.phpt b/Zend/tests/lazy_objects/gh18038-002.phpt
index 4c12f21de81..d363731c62a 100644
--- a/Zend/tests/lazy_objects/gh18038-002.phpt
+++ b/Zend/tests/lazy_objects/gh18038-002.phpt
@@ -34,5 +34,4 @@ class Proxy extends RealInstance {
 --EXPECT--
 init
 string(19) "RealInstance::__set"
-string(12) "Proxy::__set"
 int(2)
diff --git a/Zend/tests/lazy_objects/gh18038-004.phpt b/Zend/tests/lazy_objects/gh18038-004.phpt
index 8810efb6bec..c1495c5a6d8 100644
--- a/Zend/tests/lazy_objects/gh18038-004.phpt
+++ b/Zend/tests/lazy_objects/gh18038-004.phpt
@@ -36,7 +36,6 @@ public function __get($name) {
 --EXPECTF--
 init
 string(19) "RealInstance::__get"
-string(12) "Proxy::__get"

 Warning: Undefined property: RealInstance::$prop in %s on line %d
 NULL
diff --git a/Zend/tests/lazy_objects/gh18038-007.phpt b/Zend/tests/lazy_objects/gh18038-007.phpt
index 9925190a198..4c7c0d0b4b0 100644
--- a/Zend/tests/lazy_objects/gh18038-007.phpt
+++ b/Zend/tests/lazy_objects/gh18038-007.phpt
@@ -36,6 +36,5 @@ public function __isset($name) {
 --EXPECT--
 init
 string(21) "RealInstance::__isset"
-string(14) "Proxy::__isset"
 bool(false)
 bool(false)
diff --git a/Zend/tests/lazy_objects/gh18038-009.phpt b/Zend/tests/lazy_objects/gh18038-009.phpt
index 3c165a71ccf..11067cdb970 100644
--- a/Zend/tests/lazy_objects/gh18038-009.phpt
+++ b/Zend/tests/lazy_objects/gh18038-009.phpt
@@ -36,6 +36,5 @@ public function __isset($name) {
 --EXPECT--
 init
 string(21) "RealInstance::__isset"
-string(14) "Proxy::__isset"
 bool(false)
 bool(false)
diff --git a/Zend/tests/lazy_objects/gh20875.phpt b/Zend/tests/lazy_objects/gh20875.phpt
index 72e16011320..ff036edabd5 100644
--- a/Zend/tests/lazy_objects/gh20875.phpt
+++ b/Zend/tests/lazy_objects/gh20875.phpt
@@ -31,14 +31,6 @@ public function __get($name) {

 Warning: Undefined variable $v in %s on line %d

-Notice: Indirect modification of overloaded property A::$b has no effect in %s on line %d
-
-Warning: Undefined variable $x in %s on line %d
-
-Notice: Object of class stdClass could not be converted to int in %s on line %d
-
-Warning: Undefined variable $v in %s on line %d
-
 Notice: Indirect modification of overloaded property A::$f has no effect in %s on line %d

 Fatal error: Uncaught Error: Cannot assign by reference to overloaded object in %s:%d
diff --git a/Zend/tests/lazy_objects/gh21478-isset.phpt b/Zend/tests/lazy_objects/gh21478-isset.phpt
new file mode 100644
index 00000000000..9138984af01
--- /dev/null
+++ b/Zend/tests/lazy_objects/gh21478-isset.phpt
@@ -0,0 +1,30 @@
+--TEST--
+GH-21478: __isset on lazy proxy should not double-invoke when real instance guard is set
+--FILE--
+<?php
+
+class Foo {
+    public $_;
+
+    public function __isset($name) {
+        global $proxy;
+        printf("__isset(\$%s) on %s\n", $name, $this::class);
+        return isset($proxy->{$name});
+    }
+}
+
+class Bar extends Foo {}
+
+$rc = new ReflectionClass(Bar::class);
+$proxy = $rc->newLazyProxy(function () {
+    echo "Init\n";
+    return new Foo();
+});
+
+$real = $rc->initializeLazyObject($proxy);
+isset($real->x);
+
+?>
+--EXPECT--
+Init
+__isset($x) on Foo
diff --git a/Zend/tests/lazy_objects/gh21478-proxy-get-override.phpt b/Zend/tests/lazy_objects/gh21478-proxy-get-override.phpt
new file mode 100644
index 00000000000..520c8f66235
--- /dev/null
+++ b/Zend/tests/lazy_objects/gh21478-proxy-get-override.phpt
@@ -0,0 +1,30 @@
+--TEST--
+GH-21478: Proxy's own __get runs when accessed directly (not from real instance)
+--FILE--
+<?php
+
+class Foo {
+    private $_;
+
+    public function __get($name) {
+        echo __CLASS__, " ", $name, "\n";
+    }
+}
+
+class Bar extends Foo {
+    public function __get($name) {
+        echo __CLASS__, " ", $name, "\n";
+    }
+}
+
+$rc = new ReflectionClass(Bar::class);
+$proxy = $rc->newLazyProxy(function () {
+    return new Foo();
+});
+$rc->initializeLazyObject($proxy);
+
+$proxy->x;
+
+?>
+--EXPECT--
+Bar x
diff --git a/Zend/tests/lazy_objects/gh21478-proxy-get-ref-forward.phpt b/Zend/tests/lazy_objects/gh21478-proxy-get-ref-forward.phpt
new file mode 100644
index 00000000000..fa737cf18f2
--- /dev/null
+++ b/Zend/tests/lazy_objects/gh21478-proxy-get-ref-forward.phpt
@@ -0,0 +1,32 @@
+--TEST--
+GH-21478: No assertion failure when &__get forwards through initialized lazy proxy
+--FILE--
+<?php
+class Foo {
+    public $_;
+
+    public function &__get($name) {
+        global $proxy;
+        printf("%s(\$%s) on %s\n", __METHOD__, $name, $this::class);
+        return $proxy->{$name};
+    }
+}
+
+class Bar extends Foo {}
+
+$rc = new ReflectionClass(Bar::class);
+$proxy = $rc->newLazyProxy(function () {
+    echo "Init\n";
+    return new Foo();
+});
+
+$real = $rc->initializeLazyObject($proxy);
+$a = &$real->x;
+var_dump($a);
+?>
+--EXPECTF--
+Init
+Foo::__get($x) on Foo
+
+Warning: Undefined property: Foo::$x in %s on line %d
+NULL
diff --git a/Zend/tests/lazy_objects/gh21478-set.phpt b/Zend/tests/lazy_objects/gh21478-set.phpt
new file mode 100644
index 00000000000..0b2f872de11
--- /dev/null
+++ b/Zend/tests/lazy_objects/gh21478-set.phpt
@@ -0,0 +1,32 @@
+--TEST--
+GH-21478: __set on lazy proxy should not double-invoke when real instance guard is set
+--FILE--
+<?php
+
+#[AllowDynamicProperties]
+class Foo {
+    public $_;
+
+    public function __set($name, $value) {
+        global $proxy;
+        printf("__set(\$%s) on %s\n", $name, $this::class);
+        $proxy->{$name} = $value;
+    }
+}
+
+#[AllowDynamicProperties]
+class Bar extends Foo {}
+
+$rc = new ReflectionClass(Bar::class);
+$proxy = $rc->newLazyProxy(function () {
+    echo "Init\n";
+    return new Foo();
+});
+
+$real = $rc->initializeLazyObject($proxy);
+$real->x = 1;
+
+?>
+--EXPECT--
+Init
+__set($x) on Foo
diff --git a/Zend/tests/lazy_objects/gh21478-unset.phpt b/Zend/tests/lazy_objects/gh21478-unset.phpt
new file mode 100644
index 00000000000..5febbd235d8
--- /dev/null
+++ b/Zend/tests/lazy_objects/gh21478-unset.phpt
@@ -0,0 +1,30 @@
+--TEST--
+GH-21478: __unset on lazy proxy should not double-invoke when real instance guard is set
+--FILE--
+<?php
+
+class Foo {
+    public $_;
+
+    public function __unset($name) {
+        global $proxy;
+        printf("__unset(\$%s) on %s\n", $name, $this::class);
+        unset($proxy->{$name});
+    }
+}
+
+class Bar extends Foo {}
+
+$rc = new ReflectionClass(Bar::class);
+$proxy = $rc->newLazyProxy(function () {
+    echo "Init\n";
+    return new Foo();
+});
+
+$real = $rc->initializeLazyObject($proxy);
+unset($real->x);
+
+?>
+--EXPECT--
+Init
+__unset($x) on Foo
diff --git a/Zend/tests/lazy_objects/gh21478.phpt b/Zend/tests/lazy_objects/gh21478.phpt
new file mode 100644
index 00000000000..aaa226a9a09
--- /dev/null
+++ b/Zend/tests/lazy_objects/gh21478.phpt
@@ -0,0 +1,32 @@
+--TEST--
+GH-21478 (Property access on lazy proxy may invoke magic method despite real instance guards)
+--FILE--
+<?php
+
+class Foo {
+    public $_;
+
+    public function __get($name) {
+        global $proxy;
+        printf("__get(\$%s) on %s\n", $name, $this::class);
+        return $proxy->{$name};
+    }
+}
+
+class Bar extends Foo {}
+
+$rc = new ReflectionClass(Bar::class);
+$proxy = $rc->newLazyProxy(function () {
+    echo "Init\n";
+    return new Foo();
+});
+
+$real = $rc->initializeLazyObject($proxy);
+$real->x;
+
+?>
+--EXPECTF--
+Init
+__get($x) on Foo
+
+Warning: Undefined property: Foo::$x in %s on line %d
diff --git a/Zend/zend_object_handlers.c b/Zend/zend_object_handlers.c
index 648e57dfe7e..28fd8254249 100644
--- a/Zend/zend_object_handlers.c
+++ b/Zend/zend_object_handlers.c
@@ -886,6 +886,28 @@ ZEND_API zval *zend_std_read_property(zend_object *zobj, zend_string *name, int

 	retval = &EG(uninitialized_zval);

+	/* For initialized lazy proxies: if the real instance's magic method
+	 * guard is already set for this property, we are inside a recursive
+	 * call from the real instance's __get/__isset. Forward directly to
+	 * the real instance to avoid double invocation. (GH-21478) */
+	if (UNEXPECTED(zend_object_is_lazy_proxy(zobj)
+			&& zend_lazy_object_initialized(zobj))) {
+		zend_object *instance = zend_lazy_object_get_instance(zobj);
+		if (instance->ce->ce_flags & ZEND_ACC_USE_GUARDS) {
+			uint32_t *instance_guard = zend_get_property_guard(instance, name);
+			uint32_t guard_type = ((type == BP_VAR_IS) && zobj->ce->__isset)
+				? IN_ISSET : IN_GET;
+			if ((*instance_guard) & guard_type) {
+				retval = zend_std_read_property(instance, name, type, cache_slot, rv);
+				if (retval == &EG(uninitialized_zval)) {
+					ZVAL_NULL(rv);
+					retval = rv;
+				}
+				return retval;
+			}
+		}
+	}
+
 	/* magic isset */
 	if ((type == BP_VAR_IS) && zobj->ce->__isset) {
 		zval tmp_result;
@@ -1203,6 +1225,20 @@ found:;
 		goto exit;
 	}

+	/* For initialized lazy proxies: if the real instance's __set guard
+	 * is already set, we are inside a recursive call from the real
+	 * instance's __set. Forward directly to avoid double invocation. */
+	if (UNEXPECTED(zend_object_is_lazy_proxy(zobj)
+			&& zend_lazy_object_initialized(zobj))) {
+		zend_object *instance = zend_lazy_object_get_instance(zobj);
+		if (instance->ce->ce_flags & ZEND_ACC_USE_GUARDS) {
+			uint32_t *instance_guard = zend_get_property_guard(instance, name);
+			if ((*instance_guard) & IN_SET) {
+				return zend_std_write_property(instance, name, value, cache_slot);
+			}
+		}
+	}
+
 	/* magic set */
 	if (zobj->ce->__set) {
 		if (!guard) {
@@ -1595,6 +1631,21 @@ ZEND_API void zend_std_unset_property(zend_object *zobj, zend_string *name, void
 		return;
 	}

+	/* For initialized lazy proxies: if the real instance's __unset guard
+	 * is already set, we are inside a recursive call from the real
+	 * instance's __unset. Forward directly to avoid double invocation. */
+	if (UNEXPECTED(zend_object_is_lazy_proxy(zobj)
+			&& zend_lazy_object_initialized(zobj))) {
+		zend_object *instance = zend_lazy_object_get_instance(zobj);
+		if (instance->ce->ce_flags & ZEND_ACC_USE_GUARDS) {
+			uint32_t *instance_guard = zend_get_property_guard(instance, name);
+			if ((*instance_guard) & IN_UNSET) {
+				zend_std_unset_property(instance, name, cache_slot);
+				return;
+			}
+		}
+	}
+
 	/* magic unset */
 	if (zobj->ce->__unset) {
 		if (!guard) {
@@ -2412,6 +2463,20 @@ ZEND_API int zend_std_has_property(zend_object *zobj, zend_string *name, int has
 		goto exit;
 	}

+	/* For initialized lazy proxies: if the real instance's __isset guard
+	 * is already set, we are inside a recursive call from the real
+	 * instance's __isset. Forward directly to avoid double invocation. */
+	if (UNEXPECTED(zend_object_is_lazy_proxy(zobj)
+			&& zend_lazy_object_initialized(zobj))) {
+		zend_object *instance = zend_lazy_object_get_instance(zobj);
+		if (instance->ce->ce_flags & ZEND_ACC_USE_GUARDS) {
+			uint32_t *instance_guard = zend_get_property_guard(instance, name);
+			if ((*instance_guard) & IN_ISSET) {
+				return zend_std_has_property(instance, name, has_set_exists, cache_slot);
+			}
+		}
+	}
+
 	if (!zobj->ce->__isset) {
 		goto lazy_init;
 	}