/

Property hooks, and the three places they earn their keep

2,039 words, about 9 min read

On 13 January I deleted a getter and a setter from a value object and replaced them with four lines inside the property declaration. Then I opened a search for function get across the repository, got 147 matches, and closed it again. Three weeks later the count of converted pairs is nine. That is not because the feature disappoints. It is because most getters in that list are not the kind of getter a property hook is for.

We are not on 8.4 in production. One internal service is, since the middle of January, and that is where the nine live.

What shipped on 21 November

PHP 8.4 arrived on 21 November 2024, and the thing worth noticing in the announcement is the number: the release is 8.4.1, not 8.4.0. The headline features are property hooks, asymmetric property visibility, lazy objects, the new DOM API, and a set of smaller changes that each removed a workaround from our code.

Asymmetric visibility is the one I expected to use most and did not. The syntax is a second modifier on the write side, so public private(set) string $version = '8.4'; is a property anyone can read and only the class can write, with the rule that get visibility must not be narrower than set visibility. It replaces a getter plus a private property exactly, with no behaviour attached. In a codebase that already exposes most state through Doctrine entities, that turned out to be a smaller win than I assumed, for reasons I will get to.

Lazy objects are for framework authors and say so. You take a ReflectionClass, call newLazyGhost() with an initializer closure, and get back an object that runs the closure the first time anyone touches it. I have not written a line of it and I expect to benefit from it anyway, through the libraries that will stop shipping generated proxy classes.

Chaining new without parentheses landed too, so new Money(500, 'PHP')->format() parses. Small, and it removed 30-odd pairs of parentheses from tests. The new DOM API matters more than it looks: the Dom namespace ships classes that are HTML 5 capable and WHATWG spec compliant, with DomHTMLDocument::createFromString(), querySelector() and a real classList. We scrape two courier tracking pages with DOMDocument and loadHTML, and both of those parsers exist because libxml’s HTML 4 parser mangles things a browser handles fine.

The change that actually cost us time was a deprecation. A parameter whose type is implicitly widened to accept null by a null default is now deprecated, so function foo(T1 $a = null) has to become ?T1 or T1|null. That produced 412 notices in the first hour on the internal service. Mostly mechanical, except where the deprecated parameter sat before a mandatory one, because then you cannot keep the default at all and have to drop it, since an optional parameter before a required one is itself deprecated. Eleven signatures in that shape, all in code I wrote in 2019.

The three places they earn their keep

The RFC is unusually honest about its own purpose. It says a primary use case for hooks is to not use them, and to keep the ability to add them later without an API change. That is the argument for public properties everywhere and hooks nowhere until you need one. Where they do pay is narrower than the blog posts suggest.

A computed value that was only ever a method because PHP had no alternative

This is the clearest case. A read only derived value, no arguments, no failure mode, cheap to compute:

final class ShipmentLeg
{
    public function __construct(
        public readonly DateTimeImmutable $pickedUpAt,
        public readonly DateTimeImmutable $deliveredAt,
    ) {
    }

    public int $transitHours {
        get => (int) (($this->deliveredAt->getTimestamp() - $this->pickedUpAt->getTimestamp()) / 3600);
    }
}

Because the hook body never touches $this->transitHours, no backing value is created and the property is virtual: nothing in the object’s memory layout, and any write throws an Error. That detection is done at compile time on the literal $this->propertyName, so a dynamic form like $prop = 'transitHours'; $this->$prop will not give you a backing value even if you wanted one.

The payoff is not shorter code. It is that Twig templates, serializer configs and static analysis all see a typed int property instead of a method whose return type they have to go and look up. We had a getTransitHours() being called as leg.transitHours in Twig anyway, because Twig resolves that to the getter. Now the two spellings are the same thing.

Validation on assignment, in the setter nobody called

The second case is the one that found a bug. A courier reference is uppercase, trimmed, and between 6 and 20 characters. There was a setReference() that enforced that, and three places that assigned the property directly because it was public and the setter was easy to forget.

// before
private string $reference;

public function getReference(): string
{
    return $this->reference;
}

public function setReference(string $reference): void
{
    $reference = strtoupper(trim($reference));
    if (strlen($reference) < 6 || strlen($reference) > 20) {
        throw new InvalidArgumentException('bad courier reference: '.$reference);
    }
    $this->reference = $reference;
}

// after
public string $reference {
    set (string $value) {
        $value = strtoupper(trim($value));
        if (strlen($value) < 6 || strlen($value) > 20) {
            throw new InvalidArgumentException('bad courier reference: '.$value);
        }
        $this->reference = $value;
    }
}

Three things about that syntax are worth knowing before you write it. The parameter can be omitted entirely if its type matches the property type, in which case it is called $value. There is a short form, set => strtoupper(trim($value));, where the expression result is assigned to the backing value for you, which also means the short form can never produce a virtual property. And the set parameter type may be wider than the property type, so you can accept string|Stringable and normalise to string on the way in, with reads still guaranteed to give you the declared type.

The bug: one of those three direct assignments was writing a lowercase reference from a partner webhook, and a downstream === comparison against an uppercase value had been quietly failing since at least last year. Converting the setter to a hook made the assignment go through validation and the exception showed up in the first integration test run.

An interface that needs a property rather than a method

This one was not possible before 8.4 at all. Interfaces can now declare public properties and say which operations they require:

interface Traceable
{
    public string $trackingNumber { get; }
}

An implementation satisfies that with a plain public property, a get hook, a virtual property, or a public readonly property, since readonly only restricts writes. If the interface had asked for set instead, readonly would be incompatible. Omitting the hooks and writing public string $trackingNumber; in an interface is deliberately not allowed, because it would be ambiguous whether that means get only or get and set. Abstract classes get the same ability and may additionally declare the property protected.

Our three tracking implementations previously shared an interface with getTrackingNumber(): string, and two of them implemented it by returning a constructor promoted public property. That indirection is now gone.

Where a plain method is still better

The RFC argues against itself in one place, and I think it is right. On arrays, it works through the options for intercepting in place modification like $obj->items[] = $x, concludes that a complete solution is impossible, and states that “for arrays, dedicated mutator methods with a narrow contract are always the superior API choice”. So a collection stays behind addItem(). A set hook on an array property also cannot see what changed and would have to revalidate the whole array on every write.

Beyond arrays, my rule after three weeks is that a hook has to be honest about cost and failure. A property read that issues a database query is a lie, and no amount of syntax makes it not a lie. Anything that takes an argument, anything the caller is expected to handle a failure from, anything whose name reads as a verb: keep it a method. There is also a hard constraint rather than a stylistic one. A readonly property with a get or set hook is a compile error, and a child class may not add hooks to an inherited readonly property either, because readonly works by checking whether the backing value is initialised and a virtual property has nothing to check. Half our value objects are readonly, which caps how far this can go.

References are restricted too. A property with a set hook cannot be taken by reference or modified indirectly, since either would bypass the hook, and iterating an object’s properties by reference throws the moment it reaches a hooked one. Get only properties can opt into returning by reference with &get, and combining &get with set on a backed property is a compile error.

What Doctrine does with a hooked property

I assumed entity hydration would work, because Doctrine writes properties through reflection and reflection bypasses visibility. That assumption is wrong, and I would rather have found out from the docs than from production.

Hooks are not a reflection bypass. ReflectionProperty::setValue() invokes the set hook, and 8.4 adds getRawValue() and setRawValue() for reading and writing the backing value without the hooks, which throw if the property is virtual. Alongside those, ReflectionProperty gained getHooks(), getHook() with a new PropertyHookType enum, isVirtual(), getSettableType(), and an IS_VIRTUAL filter constant. An ORM has to choose deliberately between the hooked and raw paths, because the database wants the stored representation and the hook may transform it.

Doctrine has not made that choice yet. The core team’s October write up states that PHP 8.4 is supported from ORM 2.20.0 and 3.3.0, then says plainly that you cannot use property hooks in entities, because the internals need reworking around setValue() versus setRawValue(), and that trying to will throw. The upgrade notes carry a section headed “Explictly forbid property hooks”, typo included, explaining that hooks are forbidden on purpose because adding support later would otherwise be a behaviour break. The pull request that did it is nine lines of guard, and this is what you get:

LogicException: Doctrine ORM does not support property hooks in this version.
Check https://github.com/doctrine/orm/issues/11624 for details of versions
that support property hooks.

So the answer to “what does ORM hydration do with hooked properties” is that today it refuses to start. Support is planned for ORM 3.4. That rules hooks out of entities entirely for us, which is most of the 147 getters, and it is the real reason the converted count is nine: all nine are DTOs and value objects that Doctrine never sees.

Where I have landed for the next quarter: hooks on DTOs, request payloads and value objects, with a preference for no hooks at all until a property needs one. Nothing on entities until ORM 3.4 ships and I have read its upgrade notes. And asymmetric visibility, which I was most confident about in November, has exactly two uses in the codebase so far, because the properties I wanted to protect from outside writes are the ones Doctrine writes.

Sources

  • PHP news archive, 2024: the 21 November 2024 general availability announcement, which is for 8.4.1 rather than 8.4.0.
  • PHP 8.4 new features: property hooks and virtual properties, asymmetric visibility, lazy objects via newLazyGhost(), dereferencable new expressions, and the Dom namespace with HTML 5 parsing.
  • PHP RFC: Property hooks: the full and short syntax, the implicit $value parameter, contravariant set types, virtual property detection, properties in interfaces and abstract classes, the readonly and reference restrictions, the new reflection API, and the authors’ own argument that arrays are better served by mutator methods.
  • PHP RFC: Asymmetric visibility v2: the private(set) modifier and the rule that get visibility may not be narrower than set visibility.
  • PHP 8.4 deprecated features: implicitly nullable parameter types, and why a deprecated parameter sitting before a mandatory one has to lose its default.
  • Doctrine core team meetup, ORM 2.20.0 and 3.3.0: PHP 8.4 support from those versions, hooks unusable in entities, the setValue() versus setRawValue() rework, and the ORM 3.4 plan.
  • doctrine/orm UPGRADE.md: the “Explictly forbid property hooks” section and the reasoning that later support would be a behaviour break.
  • doctrine/orm pull request 11628: the guard that throws on any property with hooks, and the exact exception message.