Property hooks, and the getter that was a habit

PHP 8.4 arrived on the twenty-first of November with property hooks and asymmetric visibility. Between them they remove the reason most classes have getters at all, which turns out to be four hundred and twelve methods that return a property unchanged.

The symptom

$ ./bin/find-trivial-getters src/ | head -4
  AppDomainOrder::id()          return $this->id;
  AppDomainOrder::placedAt()    return $this->placedAt;
  AppDomainMoney::cents()       return $this->cents;
  AppDomainMoney::currency()    return $this->currency;

$ ./bin/find-trivial-getters src/ | wc -l
412

$ ./bin/find-trivial-getters src/ --with-setter | wc -l
0
# not one of them has a matching setter.

Four hundred and twelve getters and no setters at all, which tells you what the getters were for: they existed to keep the property private, and the property was private so that it could not be written. Two declarations doing the work of one idea.

Why it happens

Encapsulation by habit, in a language that had no alternative. A public property was writable by anybody, so the only way to expose a value for reading was a method — and once the method exists, nobody questions whether it earns its place.

The fix

Asymmetric visibility solves most of it on its own

// 8.3
final class Basket
{
    private array $lines = [];

    public function lines(): array { return $this->lines; }
}

// 8.4
final class Basket
{
    public private(set) array $lines = [];
}

// read as $basket->lines, written only from inside.
// protected(set) exists for a hierarchy.

This is the change that covers three hundred of the four hundred and twelve, and it is not a hook — no code runs on read. A readonly promoted property already gave this for values set once at construction; asymmetric visibility extends it to properties the class mutates over its lifetime.

Hooks, for the computed case

public Money $total {
    get => array_reduce(
        $this->lines,
        fn (Money $c, OrderLine $l) => $c->plus($l->total),
        Money::zero($this->currency),
    );
}

public string $reference {
    get => $this->reference;
    set (string $value) => strtoupper(trim($value));
}

A get-only hook with no backing property is a computed value that reads as data, which is the case this feature is genuinely for. The set hook normalising on write is the other one — it removes a class of bug where two code paths normalise differently, and it is the only place in the class the transformation appears.

The interface change, which is new

interface HasReference
{
    public string $reference { get; }
}

final class Order implements HasReference
{
    public string $reference { get => $this->ref; }
}

// and a plain property satisfies it — no hook needed
final class Invoice implements HasReference
{
    public function __construct(public string $reference) {}
}

An interface can now require a property, and an implementation may satisfy it with a plain property or with a hook — which means the interface describes what a caller can read rather than how it is provided. That is a genuine expansion of what an interface can express and it is the part of this release with the longest consequences.

Where a hook is wrong

  anything that can fail   $order->invoice throwing
    InvoiceNotRaised reads as a property access. a caller
    cannot see that it might throw.
  anything expensive   a hook that queries the database
    is a property access with a round trip in it, and
    every reader assumes property access is free.
  anything with a side effect   lazily populating a cache
    is defensible in a method and invisible in a property.

the rule: a hook must be pure, cheap and total.

Pure, cheap and total is the whole guidance, and it is the same discipline that applies to any operator overloading — the syntax says “this is a value” and the implementation has to be worthy of that claim. A hook that violates it is worse than the getter it replaced, because the getter was honest about being a call.

What we converted, and the three hundred we did not

  asymmetric visibility   188   trivial getters on
                                mutable properties
  a get hook               22   computed: total,
                                isOverdue, displayName
  a get/set hook            8   normalisation on write
  left as methods         194   41 can throw, 38 hit the
                                database, 22 take an
                                argument, 93 were already
                                readonly promoted

412 → 218 methods.

The ninety-three that needed nothing are the ones already using readonly promoted constructor properties, which had solved this problem for immutable objects in 8.1. The feature closes the gap for mutable ones, which is a smaller gap than the release notes suggest.

The performance question, measured

$ php bench/property-access.php
  plain public property        0.0021 µs/op
  asymmetric visibility        0.0021 µs/op
  get hook (computed)          0.0410 µs/op
  a method call                0.0398 µs/op

# asymmetric visibility is free — the check is at
# compile time.
# a hook costs about what a method call costs, which is
# what it is.

Asymmetric visibility being free is the important number, because it is the conversion that applies to most cases. A hook costing the same as a method call is expected and is the thing to remember when a hook is placed on a property read in a loop — the syntax hides a call that the previous syntax made obvious.

Verifying it worked

$ php -v && ./bin/find-trivial-getters src/ | wc -l
PHP 8.4.1 (cli)
0

$ vendor/bin/phpstan analyse --level=9 && vendor/bin/phpunit
 [OK] No errors
  Tests: 1,414 passed

$ ./bin/benchmark --suite=domain --compare=baseline.json
  no change beyond noise (±1.2%)

# writing to a private(set) property from outside
Error: Cannot modify private(set) property
AppDomainBasket::$lines from global scope

Confirming that the write is refused from outside is the assertion that this preserved the encapsulation the getters existed for. The benchmark showing no change is the other half — a hundred and ninety-four getters removed from hot paths and no measurable difference, which is what the per-operation numbers predicted.

What this costs

A minimum version bump three weeks after release, for readability. That is a thin justification for an internal application and would be indefensible for a library, and it was taken here because the 8.4 upgrade was happening anyway and doing the conversion in the same release meant one round of review rather than two.

The larger cost is that property access is no longer obviously free. A reader who sees $order->total now has to know whether it is a property, an asymmetric property or a hook, and only the third does work — and nothing at the call site distinguishes them. The pure-cheap-total rule is what makes that acceptable and it is a rule enforced by review.