PHP 8.4's asymmetric visibility (private(set) / protected(set)) is enforced by re-checking set access on every write: each assignment to an asymmetric property calls zend_asymmetric_property_has_set_access(), which resolves the currently executing scope and compares it against the property's declaring class. Ordinary visibility (public / protected / private) does not work this way — it is checked once per call site, when the engine's per-opline runtime cache is populated, and every subsequent execution of that opline is a plain cached property access.
The reason for the difference is structural, not intentional: the engine's shared property resolver, zend_get_property_offset(), serves reads and writes alike and does not know which it is resolving for. Get-visibility can therefore be checked at population, but set-visibility cannot — so it was bolted onto every mutation site instead.
The measurable consequence: a write to a private(set) property costs about 3× as much as a write to a plain public typed property, and protected(set) about 4× (the compatible-scope walk is longer). This is a permanent tax on adopting asymmetric visibility, paid on the hottest write path shape in the engine.
This RFC makes property resolution direction-aware and moves the asymmetric set check to cache-population time. After the first resolution at a call site, writes to asymmetric properties cost exactly the same as writes to public typed properties. The change is behavior-neutral: no observable semantics change, no error message changes, and the entire test suite passes unmodified.
zend_get_property_offset() gains a write_access flag. It is an always_inline function and every caller passes a constant, so the parameter folds away at compile time. The write, unset, and get_property_ptr_ptr handlers resolve with write access; read and isset resolve without.__set fallback — but the runtime cache slot is left unpopulated (and its key invalidated).ZEND_ASSIGN_OBJ cache slot proves set access. The cached direct-assignment fast path can therefore skip the per-write scope walk (a debug assertion enforces the invariant).$this property opcodes with the same property name, ZEND_ASSIGN_OBJ receives its own slot-sharing pool: its cache misses populate exclusively through the set-checked write handler, so sharing among assignment oplines preserves the invariant. All other property opcodes keep the shared pool and their existing per-operation checks.readonly keeps its per-write logic. Its check depends on per-object state (initialized or not), which can never amortize into a per-call-site cache. This RFC does not target readonly and does not change its cost.__set fallback semantics are preserved structurally: the cached fast path only runs when the property slot holds a defined value; every unset-state write already takes the slow handler path, where the full state-dependent handling (error vs. magic fallback) is untouched.__set call; their per-execution cost is irrelevant.+=, ++), writes through references, property-hook dispatch, and the JIT's helper paths keep their existing per-operation checks (see Future Scope).A fair question raised in early discussion: setters are often called once per object — how much does a per-write saving matter?
The key fact is that the amortization is per call site per request, not per object. The runtime cache belongs to the opline, and one opline serves every object that flows through it. The check disappears from the second execution of the *code location* onward, regardless of how many distinct objects are written. Concretely, the win shows up wherever the same assignment executes repeatedly:
private(set) position, stream wrappers tracking offsets, aggregators building totals — assign the same property every iteration.with*()/set*() calls executes per request across many instances.public private(set) is the idiomatic replacement for getter boilerplate; this RFC removes the last reason to avoid it in hot paths.Just as important is the adoption argument: asymmetric visibility currently violates “don't pay for what you don't use” in reverse — you pay *for using it*, relative to a plain public property plus discipline. After this change the two cost the same, so the feature can be chosen on design grounds alone.
Microbenchmark (release NTS build, best-of-9 over 20M tight writes from a scope with set access, loop overhead subtracted; Apple Silicon):
| net ns per write (interpreter) | master | this RFC |
|---|---|---|
public int | 1.02 | 1.04 |
private(set) int | 3.04 | 1.04 |
protected(set) int | 3.88 | 1.02 |
The same collapse holds under opcache (raw: private(set) 5.46 → 3.40 ns, protected(set) 6.22 → 3.44 ns, against public's 3.46 ns). Read performance is unchanged within noise in every mode, and whole-program Zend/bench.php is identical on both builds: the change is invisible outside asymmetric writes.
With opcache included and enabled by default as of PHP 8.5, the opcache numbers above describe the default configuration: out of the box, asymmetric-property writes reach full parity with public writes under this RFC.
The one configuration where the tax remains is the JIT, which stays opt-in. Under the tracing JIT, asymmetric writes are unchanged by this RFC — the JIT's helper paths are out of scope — and remain roughly 2.5× slower than JIT'd public writes. Note the shape of that gap: enabling the JIT speeds up public writes but not asymmetric ones, so users who adopt asymmetric properties at parity in the default configuration will see a relative penalty reappear if they later enable the JIT (no absolute regression). Teaching the JIT to inline granted asymmetric stores on the same populated-slot invariant closes this and is the follow-up with the largest remaining win; it depends on the general write-pool split discussed in Open Issues (see Future Scope).
None. The change is behavior-neutral by construction and by test: the full test suite — including the asymmetric-visibility, readonly, and property-hooks suites — passes with zero expectation changes in plain, opcache (with protect_memory), and tracing-JIT modes. Error messages, __set fallback behavior, Reflection results, and readonly semantics are identical.
One denial-of-caching detail is observable only as timing: call sites that are denied set access re-resolve on every execution instead of being cached. Such writes throw or fall back to __set in all cases, so this affects no successful code path.
zend_get_property_offset() is file-static; no object-handler signatures change; zend_get_property_info() is untouched.ZEND_ASSIGN_OBJ), costing at most one extra 24-byte cache slot triple per property name that is both read and directly assigned on $this within one function.zend_object_handlers.c, zend_execute.c, zend_vm_def.h, and Zend/Optimizer/compact_literals.c, plus VM regeneration and tests.ZEND_ASSIGN_OBJ out of the optimizer's shared property-slot pool, because it is the only consumer that trusts the populated slot. The alternative is a full read-pool/write-pool split now, which states a simpler invariant (“a write-pool slot is always set-checked”) and pre-builds the ground the follow-ups need, at the cost of a few more cache slots. Reviewer preference can decide; the change is small either way.+=, ++): move their set checks to population as well, removing their per-operation checks.Accept the direction-aware property resolution change for the next PHP minor version (8.6 if the release managers accept it during the pre-RC phase, otherwise 8.7)? Yes / No — requires 2/3 majority.
Zend/tests/asymmetric_visibility/optimizer_shared_cache_slot.phptThe change is fully implemented and ready for review as a draft PR:
property-access-refactor, ~80 lines across zend_object_handlers.c, zend_execute.c, zend_vm_def.h and Zend/Optimizer/compact_literals.c, plus VM regeneration and tests)
The PR passes the full CI matrix and the complete test suite in plain, opcache (+protect_memory), and tracing-JIT modes with zero expectation changes. The optimizer cache-slot interaction found during preliminary review by Ilija Tovilo is fixed and regression-tested in the same branch. The benchmark script used for the numbers in this RFC is included in the PR description for independent reproduction.
After the vote, this section will record the version the change is merged into and the merge commit.