DEV Community

Cover image for PHP 8.4 Property Hooks for Value Objects: Beyond readonly
Gabriel Anhaia
Gabriel Anhaia

Posted on

PHP 8.4 Property Hooks for Value Objects: Beyond readonly


Every PHP codebase has this class. You wrote one last quarter, or yesterday.

final class Email
{
    private string $value;

    public function __construct(string $value)
    {
        if (!filter_var($value, FILTER_VALIDATE_EMAIL)) {
            throw new \InvalidArgumentException("Invalid email: {$value}");
        }
        $this->value = $value;
    }

    public function value(): string { return $this->value; }
    public function domain(): string { return substr(strrchr($this->value, '@'), 1); }
    public function local(): string { return strstr($this->value, '@', true); }
}
Enter fullscreen mode Exit fullscreen mode

It works. Three getters guard one field. Then a second value object lands: Money, PhoneNumber, PostalAddress. Each repeats the same shape. Private field, constructor that validates, a getter wall in front. Half the domain layer is value(), amount(), currency(), domain(), local(). The class file is twice as long as the actual rule it encodes.

PHP 8.3 gave you readonly and readonly classes. That solved one problem: nobody, including the class itself, writes after construction. It did not solve the getter wall. Computed reads still meant accessor methods, and validated writes on the rare evolving aggregate field still meant private fields plus accessor methods plus a callsite that calls a function to read what looks like a field.

PHP 8.4 adds property hooks. A get block runs on every read. A set block runs on every write. Both live inside the property declaration. The callsite reads and writes a property; the engine routes through your code. The syntax below is verified against the property hooks RFC and the PHP 8.4 release notes.

The syntax, exactly as the RFC defines it

Property hooks replace the trailing semicolon of a property declaration with a block:

public string $name {
    get { /* ... */ }
    set { /* ... */ }
}
Enter fullscreen mode Exit fullscreen mode

A get hook runs when the property is read. A set hook runs when the property is written. Both are optional. You can declare one without the other. Three forms matter day to day.

Short form get uses arrow syntax and returns the expression:

public string $fullName {
    get => $this->first . ' ' . $this->last;
}
Enter fullscreen mode Exit fullscreen mode

Long form get is a normal block:

public string $fullName {
    get {
        return $this->first . ' ' . $this->last;
    }
}
Enter fullscreen mode Exit fullscreen mode

Set hook receives the assigned value as $value (implicit) or as a named parameter (explicit). Both forms are legal:

public string $email {
    set (string $value) {
        $this->email = strtolower($value);
    }
}

public string $email {
    set {
        $this->email = strtolower($value);
    }
}
Enter fullscreen mode Exit fullscreen mode

Two engine rules trip you up the first time:

  1. Inside a hook, $this->name refers to the backing value of that same property. Writing $this->name = $value in a set hook does not recurse; it stores the value directly. Reading $this->name in a get hook returns the stored value, not the hook output. The hooks sit outside the backing field; the field is still there.
  2. A property is virtual when its hooks never touch the backing field. If get never reads $this->name and set never writes it, no storage slot is allocated. A computed fullName that just returns $first . ' ' . $last is virtual. A virtual property cannot have a default value, and a virtual set-only property cannot be read.

Hooks work on constructor-promoted properties too, which is where most value objects live:

final class User
{
    public function __construct(
        public string $email {
            set => strtolower(trim($value));
        },
    ) {}
}
Enter fullscreen mode Exit fullscreen mode

The short-form set auto-assigns the expression result to the backing field. It is equivalent to $this->email = strtolower(trim($value)).

Email: validated write, computed reads

The opener class collapses to one constructor-promoted field and two virtual properties.

final class Email
{
    public string $value {
        set {
            $trimmed = trim($value);
            if (!filter_var($trimmed, FILTER_VALIDATE_EMAIL)) {
                throw new \InvalidArgumentException(
                    "Invalid email: {$value}"
                );
            }
            $this->value = strtolower($trimmed);
        }
    }

    public string $domain {
        get => substr(strrchr($this->value, '@'), 1);
    }

    public string $local {
        get => strstr($this->value, '@', true);
    }

    public function __construct(string $value)
    {
        $this->value = $value;
    }
}
Enter fullscreen mode Exit fullscreen mode

Three properties, no accessor methods. The callsite reads them as fields:

$email = new Email('  Gabriel@Example.COM  ');

echo $email->value;   // gabriel@example.com
echo $email->domain;  // example.com
echo $email->local;   // gabriel
Enter fullscreen mode Exit fullscreen mode

The constructor assigns $this->value = $value, which routes through the set hook. The same validation path the constructor uses runs for any future write. $domain and $local are virtual: no backing field is allocated, and the hooks compute on demand. Adding a fourth computed view (say, $normalized) is a four-line addition with no accessor method to add.

The set hook also gives you one underrated property: the hook is the only assignment path. There is no way for a subclass, a serializer that uses public-property writes, or a hydration library to bypass it without explicit reflection. The invariant lives on the field.

Email with a single backing value field and two virtual computed properties (domain and local) sharing one validation hook

Money: hook + asymmetric visibility together

Money wants more than Email. The amount is publicly readable, internally mutable for things like rounding, and every write (internal or external) has to validate. PHP 8.4 lets you stack a set hook on top of asymmetric visibility on the same field.

final class Money
{
    public private(set) int $amountInMinorUnits {
        set {
            if ($value < 0) {
                throw new \InvalidArgumentException(
                    'Money amount cannot be negative.'
                );
            }
            $this->amountInMinorUnits = $value;
        }
    }

    public readonly string $currency;

    public string $formatted {
        get {
            $major = intdiv($this->amountInMinorUnits, 100);
            $minor = $this->amountInMinorUnits % 100;
            return sprintf('%d.%02d %s', $major, $minor, $this->currency);
        }
    }

    public function __construct(int $amountInMinorUnits, string $currency)
    {
        $this->amountInMinorUnits = $amountInMinorUnits;
        $this->currency = strtoupper($currency);
    }

    public function add(self $other): self
    {
        $this->assertSameCurrency($other);
        return new self(
            $this->amountInMinorUnits + $other->amountInMinorUnits,
            $this->currency,
        );
    }

    public function roundTo(int $precision): void
    {
        $factor = 10 ** $precision;
        $this->amountInMinorUnits = (int) (
            round($this->amountInMinorUnits / $factor) * $factor
        );
    }

    private function assertSameCurrency(self $other): void
    {
        if ($this->currency !== $other->currency) {
            throw new \DomainException(
                "Currency mismatch: {$this->currency} vs {$other->currency}"
            );
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

A few things to read off this:

  • public private(set) int $amountInMinorUnits declares the asymmetric visibility: external reads, internal writes only. The visibility check runs first; if it passes, the set hook runs second. External code that tries $money->amountInMinorUnits = -50 is stopped by the visibility before the hook ever sees the value.
  • roundTo is an internal write, so the visibility allows it, and then the hook validates the result. There is one assignment path for the field. No separate assignAmount helper, no risk that a future method skips the rule.
  • $formatted is a virtual get-only property. No backing field. The callsite reads $money->formatted and the hook computes '12.50 EUR'.
  • currency is readonly. It is genuinely frozen at construction and benefits from nothing the hook system offers. Use the simpler tool when it fits.

Before 8.4, the constructor and the mutator each carried their own copy of the rule. The hook collapses them. In 8.3, you wrote a private assignAmount helper and remembered to call it from both the constructor and roundTo. In 8.4, the hook is the helper, and the language guarantees both paths go through it.

A note on the combination: an asymmetric visibility on a field with a set hook is legal, and the order is well-defined: visibility first, then hook. The combination is a lot of machinery on one field. Reach for it when the field is part of an aggregate's invariant and is internally mutable. For pure-immutable fields, readonly is cleaner. For external-write-with-validation, a plain set hook on a public field is enough.

Cart: lazy initialization without the private cache field

Lazy computation is the third pattern hooks unlock cleanly. A Cart total is expensive to compute (line items, discounts, tax), should be cached after the first read, and must invalidate when items change. Before 8.4 you wrote a private ?Money $totalCache field, a total(): Money method, and an invalidate() call inside every mutator. Three moving pieces for one cached value.

final class Cart
{
    private ?Money $totalBacking = null;

    /** @var list<LineItem> */
    public private(set) array $lines {
        set {
            $this->lines = $value;
            $this->totalBacking = null;
        }
    }

    public Money $total {
        get => $this->totalBacking ??= $this->compute();
    }

    public function __construct(array $lines)
    {
        $this->lines = $lines;
    }

    public function addLine(LineItem $line): void
    {
        $this->lines = [...$this->lines, $line];
    }

    private function compute(): Money
    {
        $sum = 0;
        $currency = $this->lines[0]->price->currency ?? 'EUR';
        foreach ($this->lines as $line) {
            $sum += $line->price->amountInMinorUnits * $line->quantity;
        }
        return new Money($sum, $currency);
    }
}
Enter fullscreen mode Exit fullscreen mode

The cache is a private field ($totalBacking), but every consumer reads $cart->total as a property. The set hook on $lines doubles as the cache invalidator: any internal assignment to $lines (including addLine's spread) clears the cache through the hook. External code cannot write $lines because of private(set), so external code cannot stale the cache without going through the aggregate.

You can collapse this further if you do not need the private cache name (make $total itself the backing field), but the explicit cache field reads cleaner when the compute path is non-trivial.

Cart with a private cache field, a virtual total property whose get hook reads through the cache, and a lines set hook that invalidates on every mutation

When to reach for hooks, when not to

Cart is one shape; the decision is wider. Here is a short list distilled from migrating a domain layer:

  • Field that never changes after construction → readonly. Identity types (OrderId, CustomerId, Currency codes). No hook earns its place here.
  • Field with external-read, internal-write, single validation rule → public private(set) with a set hook. Money::amountInMinorUnits, Order::status. The hook owns the rule; visibility owns the access.
  • Computed view of one or more fields → virtual get hook. Email::domain, Money::formatted, Cart::total. The accessor disappears; the callsite reads a field.
  • Lazy-cached computation → virtual get with ??= plus a set hook on the source field to invalidate. Three lines for what used to be a private cache + getter method + mutator-side invalidate call.
  • Plain DTO with no rules → plain public fields, no hooks. Hooks have a small runtime cost and a real reading cost. A CreateUserRequest DTO with five public scalars does not benefit from any of this.

Two things to know before refactoring an existing layer:

  • $this->name inside a hook is the backing value, not a recursive call. Once that clicks, the code reads naturally. Before it clicks, it looks like infinite recursion. (It is not. The engine resolves the property access on $this inside a hook to the backing slot directly.)
  • Hooks plus reflection. ReflectionProperty gained methods to inspect hooks (getHooks, hasHooks). Serializers, hydrators, and ORMs that bypass methods to write fields directly will start triggering your set hooks. That is usually what you want (your validation runs on hydration), but worth checking on existing libraries. Check your ORM's PHP 8.4 release notes before upgrading.

What this changes in a Decoupled PHP codebase

If you write hexagonal PHP with a clean domain layer, hooks remove three patterns at once: the getter wall in front of value objects, the private-cache-plus-method dance for lazy fields, and the temptation to expose a public field "just for serialization" and lose the invariant.

The migration on an existing codebase, in three passes:

  1. Value objects with computed accessors. Email::domain(), Money::formatted(), PhoneNumber::countryCode() become virtual get hooks. Delete the methods. grep -R '->domain()' becomes ->domain. Run the tests.
  2. Fields with one validation rule plus internal mutation. Move the rule from the constructor (and any helper method) onto a set hook on the field. Combine with private(set) if external code should not write it. Delete the helper method.
  3. Lazy-cached computations. Replace ?T $cache + getter + invalidator with a virtual get hook using ??= plus a set hook on the source field that nulls the cache.

PHP 8.3 made write-once a one-word affair. PHP 8.4 makes computed reads, validated writes, and lazy initialization a one-block affair. The domain layer gets shorter and the invariants get harder to bypass. Callsites stop calling functions for what should be a property.

Upgrade your CI to 8.4 and start deleting getters.


If this was useful

The hexagonal-PHP layout, the value-object patterns, and the rest of the domain-modelling toolkit get the long-form treatment in Decoupled PHP. It is written for engineers who ship PHP for a living and want their domain code to outlast the framework. Property hooks are one of the language features the book uses; the rest is the discipline around them.

Decoupled PHP — Clean and Hexagonal Architecture for Applications That Outlive the Framework

Available on Kindle, Paperback, and Hardcover. English, German, and Japanese editions out now. Portuguese and Spanish coming soon.

Top comments (0)