- Book: Decoupled PHP — Clean and Hexagonal Architecture for Applications That Outlive the Framework
- Also by me: Thinking in Go (2-book series) — Complete Guide to Go Programming + Hexagonal Architecture in Go
- My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools
- Me: xgabriel.com | GitHub
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); }
}
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 { /* ... */ }
}
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;
}
Long form get is a normal block:
public string $fullName {
get {
return $this->first . ' ' . $this->last;
}
}
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);
}
}
Two engine rules trip you up the first time:
-
Inside a hook,
$this->namerefers to the backing value of that same property. Writing$this->name = $valuein a set hook does not recurse; it stores the value directly. Reading$this->namein a get hook returns the stored value, not the hook output. The hooks sit outside the backing field; the field is still there. -
A property is virtual when its hooks never touch the backing field. If
getnever reads$this->nameandsetnever writes it, no storage slot is allocated. A computedfullNamethat just returns$first . ' ' . $lastis 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));
},
) {}
}
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;
}
}
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
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.
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}"
);
}
}
}
A few things to read off this:
-
public private(set) int $amountInMinorUnitsdeclares the asymmetric visibility: external reads, internal writes only. The visibility check runs first; if it passes, thesethook runs second. External code that tries$money->amountInMinorUnits = -50is stopped by the visibility before the hook ever sees the value. -
roundTois an internal write, so the visibility allows it, and then the hook validates the result. There is one assignment path for the field. No separateassignAmounthelper, no risk that a future method skips the rule. -
$formattedis a virtualget-only property. No backing field. The callsite reads$money->formattedand the hook computes'12.50 EUR'. -
currencyisreadonly. 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);
}
}
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.
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,Currencycodes). No hook earns its place here. -
Field with external-read, internal-write, single validation rule →
public private(set)with asethook.Money::amountInMinorUnits,Order::status. The hook owns the rule; visibility owns the access. -
Computed view of one or more fields → virtual
gethook.Email::domain,Money::formatted,Cart::total. The accessor disappears; the callsite reads a field. -
Lazy-cached computation → virtual
getwith??=plus asethook 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
publicfields, no hooks. Hooks have a small runtime cost and a real reading cost. ACreateUserRequestDTO with five public scalars does not benefit from any of this.
Two things to know before refactoring an existing layer:
-
$this->nameinside 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$thisinside a hook to the backing slot directly.) -
Hooks plus reflection.
ReflectionPropertygained methods to inspect hooks (getHooks,hasHooks). Serializers, hydrators, and ORMs that bypass methods to write fields directly will start triggering yoursethooks. 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:
-
Value objects with computed accessors.
Email::domain(),Money::formatted(),PhoneNumber::countryCode()become virtualgethooks. Delete the methods.grep -R '->domain()'becomes->domain. Run the tests. -
Fields with one validation rule plus internal mutation. Move the rule from the constructor (and any helper method) onto a
sethook on the field. Combine withprivate(set)if external code should not write it. Delete the helper method. -
Lazy-cached computations. Replace
?T $cache + getter + invalidatorwith a virtualgethook using??=plus asethook 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.
Available on Kindle, Paperback, and Hardcover. English, German, and Japanese editions out now. Portuguese and Spanish coming soon.



Top comments (0)