Skip to content

protected(set) / private(set) not enforced after a write at the same call site was routed to __set() #24250

Description

@DirkHoffman

Description

The following code:

<?php
class A {
    public protected(set) int $x = 1;
    public function __construct(bool $unset) {
        if ($unset) unset($this->x);
    }
    public function __set($name, $value) {}
}

function write(A $a, int $v) { $a->x = $v; }

write(new A(true), 1); // routed to __set()
$a = new A(false);
write($a, 42);
var_dump($a->x);

Resulted in this output:

int(42)

But I expected this output instead (as in PHP 8.5.11 and 8.4.26):

Fatal error: Uncaught Error: Cannot modify protected(set) property A::$x from global scope in %s:10

Same with private(set), with and without OPcache. It also happens when a child class writes a parent's private(set) property, and with a public private(set) readonly property written in a child class's __clone(). JIT-compiled code is not affected (opcache.jit=1205 throws the expected Error).

LLM-generated analysis: Regression from 94136cf (GH-22709). The ZEND_ASSIGN_OBJ fast path passes check_writable = false when the cache slot is primed (zend_vm_def.h#L2529), relying on zend_std_write_property() resetting the slot when the set-visibility check fails (zend_object_handlers.c#L1138). When the property is unset and the class has __set(), error is false (#L1124), so the write is routed to __set() without a set-visibility check while the slot primed by zend_get_property_offset() is kept. The next execution of the same opline on an object of the same class with the property initialized then takes the fast path. Resetting the slot on that path fixes it:

 				guard = zend_get_property_guard(zobj, name);
 				error = (*guard) & IN_SET;
+				if (!error
+				 && cache_slot
+				 && (prop_info->flags & ZEND_ACC_PPP_SET_MASK)
+				 && !zend_asymmetric_property_has_set_access(prop_info)) {
+					CACHE_POLYMORPHIC_PTR_EX(cache_slot, NULL, NULL);
+					CACHE_PTR_EX(cache_slot + 2, NULL);
+				}

With this change Zend/tests passes with and without OPcache on a debug build of 8.6.0RC3. I have a regression test ready and can open a PR against PHP-8.6.

Found and verified with the help of AI tools; all outputs above were reproduced on the official php Docker images.

PHP Version

PHP 8.6.0RC3 (cli) (built: Oct  9 2026 00:13:19) (ZTS)
Copyright © The PHP Group and Contributors
Built by https://github.com/docker-library/php
Zend Engine v4.6.0RC3, Copyright © Zend by Perforce
    with Zend OPcache v8.6.0RC3, Copyright ©, by Zend by Perforce

Operating System

Debian (official php:8.6.0RC3-cli Docker image)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions