# A property hook emits the same opcode as a plain property

> Plain, backed and virtual property reads compile to one identical FETCH_OBJ_R. What separates them is a function call the opcode does not show, measured at nine times.

- Published: 2026-10-04
- Tags: zend-vm, benchmarking, memory
- Source: https://elephantphp.com/blog/property-hooks-and-what-the-compiler-emits/
- Language: en-US
- Author: Alden Pike

---
These three property declarations — the syntax
[the property hooks RFC added in 8.4](https://wiki.php.net/rfc/property-hooks) — do
three different things at run time. The compiler emits the same opcode for all of
them.

```php
<?php

final class Plain { public int $v = 0; }
final class Backed { public int $v = 0 { get => $this->v; } }
final class Virt { public int $u = 0; public int $v { get => $this->u; } }

function readPlain(Plain $o): int { return $o->v; }
function readBacked(Backed $o): int { return $o->v; }
function readVirtual(Virt $o): int { return $o->v; }
```

```text
readPlain:
0000 CV0($o) = RECV 1
0001 T1 = FETCH_OBJ_R CV0($o) string("v")
0002 RETURN T1

readBacked:
0000 CV0($o) = RECV 1
0001 T1 = FETCH_OBJ_R CV0($o) string("v")
0002 RETURN T1

readVirtual:
0000 CV0($o) = RECV 1
0001 T1 = FETCH_OBJ_R CV0($o) string("v")
0002 RETURN T1
```

One of those reads a slot. One calls a function that reads a slot. One calls a
function that reads a different slot. The dump cannot tell them apart, and
neither can a reviewer looking at the call site.

## How these numbers were taken

PHP 8.5.10, NTS, arm64, Homebrew build, on an Apple M4 Pro laptop with 24 GB and
12 logical cores, running macOS and not quiesced. This build has OPcache compiled
in rather than as a shared extension, so `--ri "Zend OPcache"` answers where
`--ri opcache` does not.

The archive's default is OPcache and the JIT off, and the headline table below
keeps it. Two extra configurations are labeled where they appear: OPcache on with
the JIT off, and the JIT in both of its modes. Opcode dumps come from
`opcache.opt_debug_level=0x20000`, which prints each function after the optimizer
has run — the same output
[the request-lifecycle post reads a `$_main` from](/blog/what-happens-when-php-runs-your-code/):

```bash
php -d opcache.enable_cli=1 -d opcache.opt_debug_level=0x20000 dump-access.php
```

Timings are the median of 15 interleaved runs with three warmup rounds
discarded, taken with `hrtime()` inside the process over 5,000,000 iterations,
with the range given. Every timing is reported net of an empty loop measured the
same way in the same configuration, because at these magnitudes the loop is most
of the number. Memory figures are exact `memory_get_usage()` deltas. Read the
timings relative to each other rather than as absolutes, for
[the reasons the method post sets out](/blog/benchmarking-php-without-lying-to-yourself/).

## The hook is a function, and the dump says so

The same dump that shows three identical call sites also shows what the hook
actually is, further down its own output:

```text
Backed::$v::get:
0000 T0 = FETCH_OBJ_R THIS string("v")
0001 RETURN T0

Virt::$v::get:
0000 T0 = FETCH_OBJ_R THIS string("u")
0001 RETURN T0
```

Each hook is compiled into a function with its own opcode array and its own name
— the property's name with `::get` after it. Nothing in the calling function
references them. The connection is made at run time, by the handler behind
`FETCH_OBJ_R`, which checks whether the property it resolved carries hooks and
calls one if it does.

That is the whole mechanism, and it is why the cost does not appear where the
cost is incurred.

One detail inside `Backed::$v::get` is worth reading twice: `$this->v` compiled
to a plain `FETCH_OBJ_R`, not to another hook invocation. Inside its own hook, a
backed property refers to the backing store, which is what stops the obvious
infinite recursion.

The getter is the only one of the four shapes that looks different:

```text
readGetter:
0000 CV0($o) = RECV 1
0001 INIT_METHOD_CALL 0 CV0($o) string("getV")
0002 V1 = DO_FCALL
0003 VERIFY_RETURN_TYPE V1
0004 RETURN V1
```

Four opcodes against one, and an explicit call. Hold onto that, because it is
about to be the cheaper of the two.

## A virtual property occupies no slot

The memory side matches the promise. A virtual property has no backing store, so
it takes nothing from
[the property table this series measured at 40 bytes plus 16 a slot](/blog/objects-are-cheaper-than-arrays/):

```text
ThreeOnly            96 bytes
ThreePlusVirtual     96 bytes
Four                112 bytes
FourBackedHooks     112 bytes
```

Three declared properties and three-plus-one-virtual are the same object. Four
declared properties and four properties with backing hooks are also the same
object. So the memory question has a clean answer: a hook on a backed property
costs nothing, and a virtual property saves you the slot a computed value would
otherwise have taken if you had cached it.

## What a read costs

Five million reads of one integer, net of a 2.986 ns empty loop:

| Read | Total | Net |
|---|---|---|
| Plain property | 4.323 ns (4.307–4.403) | 1.337 ns |
| Getter method | 11.807 ns (11.534–12.249) | 8.821 ns |
| Virtual hook | 14.314 ns (13.525–14.592) | 11.328 ns |
| Backed hook | 15.054 ns (14.566–15.344) | 12.068 ns |
| Virtual hook doing arithmetic | 15.825 ns (15.324–16.108) | 12.839 ns |
| `__get` | 29.880 ns (28.048–30.541) | 26.894 ns |

The backed hook is 9.0 times the plain property it is written to look like, and
1.37 times the getter method it is written to replace. The arithmetic inside the
third hook — a multiply and an add — cost 1.5 ns of the 12.8, so almost all of
what a hook costs is the fact of being a hook.

`__get` is more than twice a hook, which is the comparison the hooks feature was
designed to win and does.

Running the same benchmark with OPcache enabled and the JIT off reproduced every
row within its range, which is expected: OPcache changes what compilation costs,
not what dispatch costs.

## A hook you do not declare is free

The branch that runs all of this is in `zend_std_read_property` in
[`zend_object_handlers.c`](https://github.com/php/php-src/blob/PHP-8.5/Zend/zend_object_handlers.c), and it is selected by
the property's offset rather than by anything at the call site:

```c
} else if (IS_HOOKED_PROPERTY_OFFSET(property_offset)) {
    zend_function *get = prop_info->hooks[ZEND_PROPERTY_HOOK_GET];
    if (!get) {
        ...
        /* Cache the fact that this hook has trivial read. This only applies to
         * BP_VAR_R and BP_VAR_IS fetches. */
        ZEND_SET_PROPERTY_HOOK_SIMPLE_READ(cache_slot);

        retval = OBJ_PROP(zobj, prop_info->offset);
```

A hooked property with no `get` hook reads its slot directly and records that
fact in the call site's cache slot, so subsequent reads skip the check entirely.
That predicts something the table above does not contain, and the prediction
holds:

| Read | Total | Net |
|---|---|---|
| Plain property | 4.373 ns (4.346–4.516) | 1.347 ns |
| Property with only a `set` hook | 4.569 ns (4.533–4.718) | 1.543 ns |
| Property with a `get` hook | 15.012 ns (14.559–15.491) | 11.986 ns |

Declaring a `set` hook costs the read nothing measurable — 0.196 ns, which is
inside the run-to-run spread of the plain row. The cost belongs to the `get`
hook specifically, and validation on write, which is the case most hooks exist
for, does not have to buy it.

## Writes cost more than reads

The `set` side is where the gap is widest:

| Write | Total | Net |
|---|---|---|
| Plain property | 4.291 ns (4.261–4.340) | 1.305 ns |
| Setter method | 12.798 ns (12.540–13.268) | 9.812 ns |
| `set` hook | 23.892 ns (23.179–24.527) | 20.906 ns |

A `set` hook is 2.13 times its equivalent setter method and 16 times a plain
assignment. A hook that validates on write — the case the feature exists for —
starts from there and adds whatever the validation costs.

## The JIT makes it worse

The obvious hope is that the JIT closes this. It does the opposite. The same
benchmark under the tracing JIT, net of a 1.184 ns baseline in that
configuration:

| Access | No JIT | Tracing JIT |
|---|---|---|
| Plain read | 1.337 ns | 0.388 ns |
| Getter method | 8.821 ns | 3.281 ns |
| Virtual hook read | 11.328 ns | 19.250 ns |
| Backed hook read | 12.068 ns | 25.484 ns |
| Plain write | 1.305 ns | 2.396 ns |
| Setter method | 9.812 ns | 4.771 ns |
| `set` hook | 20.906 ns | 27.567 ns |

The JIT cut the plain read to under half a nanosecond and the method call to a
third. The hooked read went up. Whatever the JIT is doing with the loop, the
hook call is not part of it, and the two shapes end up 7.8 times apart in the
direction opposite to the one the syntax suggests.

The function JIT gives the same shape with smaller numbers — 0.818 ns plain,
4.769 getter, 26.091 backed hook, net of a 2.941 ns baseline in that mode — so
this is not a quirk of trace compilation. Both modes optimize the plain access
and the method call, and neither reaches the hook.

This is behavior on 8.5.10, not a defect anyone has promised to keep. It is worth
re-measuring on your own version before acting on it, which is a sentence I would
write about any JIT number.

## How many accesses before it shows

None of this decides anything on its own, because a nanosecond is a nanosecond.
The number that decides it is the access count.

Without the JIT, hook minus getter is 3.247 ns, so about 308,000 accesses in a
request buy one millisecond of difference. With the tracing JIT the difference is
22.203 ns, and 45,000 accesses buy the same millisecond.

Those are not absurd counts for a hydration path. An ORM result set of 5,000
entities with twelve accessed fields each is 60,000 reads before anything else
happens, and a template that touches the same view model repeatedly multiplies it
again.

The reference most advice traces to is [Tideways' 8.4
benchmark](https://tideways.com/profiler/blog/php-8-4-property-hooks-can-we-get-rid-of-getters-setters-now-a-benchmark),
which reports "only a 9% performance difference" between hooks and getter/setter
methods and concludes that "performance should not be a concern" in the choice
between them. The article's body does not state its PHP version, iteration count
or JIT configuration — the code is behind a link — so I cannot reconcile the two
results, only report mine and its conditions. On this build the gap is 37%
without the JIT and 5.5 to 7.8 times with it, and against a plain property, which
that discussion describes as nearly identical, it is 9.0 times.

Where the two accounts agree is the part that matters most: at ordinary access
counts, property access is a small share of a request, and this difference is
noise. The disagreement is about what "ordinary" is, and that is a number in your
own profile rather than in either of our benchmarks.

## What to do with an accessor

Convert for the API, not for the speed. A hook removes a method from the public
surface and lets a property become computed without breaking callers, and that is
worth something the table cannot price. What the table says is that you are not
also getting the plain property's cost when you do it — you are getting slightly
more than the method call you removed, and considerably more once a JIT is
involved.

Leave the hot path alone until you have counted it. The place this shows up is
not a class with a hook; it is a loop that reads one hooked property per row over
a large result set. Count the accesses, multiply by the difference in the table,
and compare it to the request time you are trying to protect.

Reach for virtual properties where a value is derived and small. They cost the
same read as a backed hook and they save the slot, so a value object that
computes rather than stores is smaller and no slower than one that caches into a
property — which is the case where hooks give you something the getter never did.

And if the reason for the hook is validation, declare only the `set` hook. The
read stays at plain-property speed because the engine has nothing to call, and
the expensive half of the feature is the half you were not using.
