Table of Contents

PHP RFC: Direction-Aware Property Resolution

Introduction

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 (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.

Proposal

Mechanics

What deliberately does not change

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:

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

Open Issues

  1. 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

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

Implementation

The change is fully implemented and ready for review as a draft PR:

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