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;
}