A year of property hooks, and the three I would not write again

In December 2024 I converted two hundred and eighteen getters into properties, wrote down a rule — a hook must be pure, cheap and total — and violated it three times within a year. All three were mine, which is the useful part of this.

The symptom

// what I wrote in March
public ?Invoice $invoice {
    get => $this->invoices->findForOrder($this->id);
}

// and what somebody wrote in April, reasonably
foreach ($orders as $order) {
    if ($order->invoice !== null) {
        $total = $total->plus($order->invoice->total);
    }
}
// two accesses, 412 orders, 824 queries
the profile of the affected endpoint:

  before   4 queries, 88ms
  after    824 queries, 3,140ms

and nothing at the call site suggests a query. the
reader sees a property access, twice, which is what
the syntax is for.

Why it happens

A property access is understood by every reader to be free, and a hook can contain anything. The syntax makes a claim about cost that the implementation is not required to honour, and there is no marker at the call site.

The fix

The three violations

  Order::$invoice   a repository call. reverted in March.

  Customer::$lifetimeValue   an aggregate query behind a
    cache, so fast most of the time and occasionally
    400ms — worse than consistently slow. June.

  Basket::$isEligibleForFreeDelivery   pure, cheap, and
    NOT total: it throws when the address is unset. a
    property that throws is the one I would defend
    least. September.

The third is the interesting one because it satisfies two of the three conditions and fails on the one nobody thinks about. A property access that can throw gives a caller no signal that a try block might be appropriate, and the exception surfaces in a stack frame that looks like a field read.

Where they were right

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

    public bool $isEmpty {
        get => $this->lines === [];
    }
}

A computed value over data the object already holds is exactly the case, and there are twenty-two of them. The total is a fold over an array in memory — it is not free, and it is bounded by the object’s own contents, which is what “cheap” has to mean in practice.

Where asymmetric visibility was the whole answer

// what I wrote in 2024
public array $lines {
    get => $this->lines;
}
private array $lines = [];

// what it should have been
public private(set) array $lines = [];
of the 218 conversions:

  188  should have been asymmetric visibility. a get
       hook returning a backing property unchanged is
       a method call where a compile-time check would
       do.
   22  genuinely computed. correct as hooks.
    8  get/set pairs normalising on write. correct.

so 86% of the conversion was the wrong feature, and it
works and costs about 40 nanoseconds each.

Eighty-six per cent reaching for the more powerful feature is what happens when two things ship in the same release and one of them is more interesting. The cost is negligible and the correction is mechanical, and the reason to do it is that a hook signals “something happens here” and these do not.

A rule the analyser can check

public function processNode(Node $node, Scope $scope): array
{
    if (! $this->isInsideAGetHook($scope)) {
        return [];
    }

    // a call on anything other than $this or a value object
    return $this->isCallOnACollaborator($node, $scope)
        ? [RuleErrorBuilder::message(
            'a get hook must not call a collaborator',
          )->identifier('turkerdev.hookPurity')->build()]
        : [];
}
the rule is crude and produces false positives on
value objects, so it carries an allow-list of twelve
classes.

it would not have caught the third violation, which
throws rather than calling anything.

so: one of three, mechanically, and the other two are
still review questions. which is honest — the
interesting failures are not syntactic.

The interface case, one year on

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

// a value object satisfies it with a plain property
final readonly class Invoice implements HasReference
{
    public function __construct(public string $reference) {}
}

// and a computed one satisfies it with a hook
final class Order implements HasReference
{
    public string $reference { get => $this->buildReference(); }
}

This is the part of the feature with the longest consequences and it has held for a year — an interface describing what a caller reads rather than how it is provided means a value object and a computed one are interchangeable. It is also the one place a hook is invisible by design, which is the same property that produced the three violations.

Verifying it worked

$ vendor/bin/phpstan analyse --level=9
 [OK] No errors

$ ./bin/count-accessors
  asymmetric visibility  188
  get hooks               19   # was 22, minus 3
  get/set hooks            8
  methods                194

$ ./bin/benchmark --suite=domain --compare=2024-12.json
  no change beyond noise (±0.9%)

$ ./bin/query-count /api/orders
  4        # was 824 in April

The benchmark showing no change across a year of conversions is the check that the syntax carries no cumulative cost, and it is a comparison against a stored baseline rather than a reading of one profile. Twenty minutes to establish that nothing happened, against assuming it.

What this costs

A syntax that hides work, enforced by a rule that catches one of three failure modes. The other two — expensive and non-total — are review questions, which means the protection is a person paying attention rather than a mechanism.

It also removed a signal that used to be free. Before this, a pair of parentheses at the call site told a reader that something happens, and now it does not — which is a small loss on every read of the codebase, forever, against a syntax that is genuinely nicer where it belongs.