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).
* This establishes the invariant: a populated 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).
* Because the optimizer's literal-compaction pass shares runtime cache slots among $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.
==== What deliberately does not change ====
* 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.
* Denied writes stay on the slow path permanently (their call sites are never cached). Denied writes end in an exception or a set call; their per-execution cost is irrelevant.
* Reads are untouched. On a cache hit the read path never enters any changed code.
* Static properties, compound assignment (+=, ++), writes through references, property-hook dispatch, and the JIT's helper paths keep their existing per-operation checks (see Future Scope).
===== Where the impact lands =====
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:
* Object hydration. An ORM or serializer materializing 10,000 rows runs the same constructor/setter assignments 10,000 times. With asymmetric properties (a natural fit for entities: publicly readable, internally writable), today that is 10,000 scope walks per property; with this RFC it is one.
* Writes in loops. Accumulator and state objects — counters, parsers and tokenizers advancing a private(set) position, stream wrappers tracking offsets, aggregators building totals — assign the same property every iteration.
* Builders and fluent APIs, where a chain of with*()/set*() calls executes per request across many instances.
* Value objects with guarded invariants. 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.
===== Performance =====
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).
===== Backward Incompatible Changes =====
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.
===== RFC Impact =====
* To extensions: none in this cut. zend_get_property_offset() is file-static; no object-handler signatures change; zend_get_property_info() is untouched.
* To opcache: one contained change in the literal-compaction pass (a separate slot-sharing pool for 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.
* Diff size: roughly 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.
===== Open Issues =====
- Minimal vs. general slot-pool split. This RFC splits only 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.
===== Future Scope =====
* JIT: inline granted asymmetric stores using the same populated-slot invariant (requires the general write-pool split, and an audit of the JIT's use of runtime cache slots).
* Compound assignment and inc/dec (+=, ++): move their set checks to population as well, removing their per-operation checks.
* Static properties: the same treatment for the static-property fetch path.
* Per-direction hook caching: a property with only a get hook could regain the fast path for writes, and vice versa.
* A single population-time enforcement seam also simplifies any future per-direction visibility rule.
===== Proposed Voting Choices =====
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.
===== Patches and Tests =====
* Regression test for the optimizer slot-sharing interaction: Zend/tests/asymmetric_visibility/optimizer_shared_cache_slot.phpt
* Benchmark script included in the PR description; results reproduced above.
===== Implementation =====
The change is fully implemented and ready for review as a draft PR:
* https://github.com/php/php-src/pull/22709** (branch 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.
===== Changelog =====
* 1.0: Initial version, incorporating the review fix for optimizer cache-slot sharing (found by Ilija Tovilo).