Property Hooks (PHP 8.4+)
Use PHP 8.4's property hooks to attach get/set behavior directly to a property declaration — building computed, validated, and normalized properties without writing traditional getter/setter boilerplate — while avoiding recursive-access and side-effect pitfalls.
Before PHP 8.4, giving a property custom read or write behavior meant making it private and hand-writing a getX()/setX() pair, or leaning on the (fairly clunky) magic methods __get()/__set(). Property hooks, added in PHP 8.4, let you attach that logic directly to the property declaration itself: public string $name { set => trim($value); } looks like a plain property from the outside — $user->name = ' Ada ' — but runs your code on every read or write. This lesson builds up everything you can do with hooks: validation, normalization, computed values, inheritance, and how hooks interact with readonly properties and constructor promotion.
The Basic get/set Hook Syntax
A property hook (PHP 8.4+) is declared inside braces immediately after a property's type, in place of (or in addition to) a default value. The two hooks are get and set. A set hook runs whenever code outside the hook assigns to the property; inside it, a special variable $value holds the value being assigned, and the hook's job is to compute what actually gets stored. A get hook runs whenever code reads the property, and its return value is what the reader sees.
The short-arrow form set => trim($value); is sugar for a single-expression hook body — the same short-arrow style used by arrow functions. For anything more than one expression, use a full block body with braces: set { $this->name = trim($value); }. Note the difference in how the two forms end up storing the value: the short-arrow form's expression result is implicitly what gets stored (when the hook is on a property with real backing storage), while a block-bodied hook must explicitly assign to the backing storage itself when one exists, or return a value when computing on the fly.
Hooks are opt-in per property — a class can mix hooked properties and plain properties freely, and you only pay the hook's small overhead on properties that actually declare one. A property with a get hook but no set hook remains writable through normal means; a property with a set hook but no get hook is written the ordinary way you'd expect but read straight from the property's stored value.
• Hooks live in braces after the property's type: public string $name { set => trim($value); } (PHP 8.4+). • Inside a set hook, $value is the incoming assigned value; the hook decides what is actually stored. • Inside a get hook, the hook's return value is what the caller sees when reading the property. • Short-arrow (=>) hooks are single-expression; block hooks ({ ... }) can contain arbitrary statements. • Hooks are per-property and opt-in — plain properties and hooked properties coexist in the same class.
Backed vs Computed Properties
A property with a set hook (like the User::$name example) is still "backed" — PHP still allocates real storage for it, and the hook intercepts assignment on the way in, but a plain read returns whatever was last stored (unless a get hook is also present to intercept reads too). This is the common case: you want the stored value, just cleaned up or validated first.
A fully computed property has no backing storage of its own at all — it exists purely as a get hook (and optionally a set hook) that derives its value from other properties every time it's read. For example, a Rectangle class might store $width and $height as plain properties and expose a computed $area with only a get hook that returns $this->width * $this->height, recomputed fresh on every access rather than cached. A computed-only property (get hook, no backing field, no set hook) is effectively read-only from the outside even without declaring readonly, because there's nothing to assign to.
Whether a property is backed or purely computed is a design decision with real tradeoffs: a backed hook property costs one validation/normalization pass at write time and then reads are free; a purely computed property recomputes on every single read, which is simpler to keep consistent (it can never go stale relative to its inputs) but costs more if it's read frequently and the computation is nontrivial.
• Backed hook property: real storage exists; set hook transforms input before storing; get hook (if present) transforms output on read. • Computed property: no backing storage; a get hook alone derives the value from other properties on every access. • A get-only computed property is effectively read-only from outside the class, with no readonly keyword needed. • Backed = pay cost once at write; computed = pay cost on every read, but can never go stale.
Validation and Normalization in set Hooks
The most common real use of a set hook is normalization: reshaping an incoming value into the canonical form you want stored, without the caller having to remember to do it themselves. The User::$name example — set => trim($value); — is normalization: leading/trailing whitespace is stripped on every assignment, so $user->name = ' Ada ' and $user->name = 'Ada' end up identically stored, and every reader downstream can rely on that invariant without re-checking it.
Validation goes further: instead of (or in addition to) transforming the value, the hook rejects invalid input outright, typically by throwing an exception. A block-bodied set hook can validate before storing: set { if ($value < 0) { throw new InvalidArgumentException('age must be non-negative'); } $this->age = $value; }. This means an object can never enter an invalid state through property assignment — the validation lives in exactly one place (the property declaration) instead of being duplicated across every constructor and setter that might touch that field.
Combining both is common: normalize first, then validate the normalized form. For an email property you might lower-case and trim in the set hook, then check the result looks like a valid address before storing it, throwing if not. Because the hook runs on every assignment — not just ones from a constructor — this guarantee holds for the entire lifetime of the object, including later reassignment, which hand-rolled validation in a constructor alone cannot guarantee.
• Normalization: reshape the incoming value into a canonical form (trim, lower-case, round, cast) before storing. • Validation: reject invalid input by throwing from inside the set hook, before anything is stored. • Validation in a hook applies to every assignment for the object's whole lifetime, not just construction. • A common pattern is normalize-then-validate inside a single block-bodied set hook.
Hook Inheritance and readonly Interaction
Property hooks can be inherited and overridden much like methods. If a parent class declares a property with hooks, a child class can override just the hooks (not necessarily the whole property) by redeclaring the property with new hook bodies — the child's hooks replace the parent's for that property. This lets a base class establish a validation contract (e.g. "$price is always stored non-negative") that subclasses can tighten but should not weaken, similar in spirit to the Liskov substitution guidance for overriding regular methods.
Hooks interact with readonly in a specific way: a readonly property can still declare a get hook (to compute a derived read-time value, or to lazily initialize on first read), but it cannot declare a set hook that runs on ordinary assignment from outside the declaring scope, because readonly already forbids that kind of external reassignment — the two features are not contradictory, but a set hook only makes sense on a property that can actually be set more than once. In practice, readonly properties most commonly pair with a get hook alone, when you want a value computed once and then exposed read-only, or exposed transformed at read time.
Constructor property promotion (available since PHP 8.0) can be combined with hooks in PHP 8.4+: you can promote a constructor parameter directly into a hooked property, e.g. public function __construct(public string $name { set => trim($value); }) {}. The hook still runs on the initial assignment made by promotion itself, so even the very first value set through the constructor gets normalized/validated — there's no gap where an unhooked initial value could sneak in.
• A child class can override a parent's hooks by redeclaring the property with new hook bodies. • A readonly property can have a get hook; a set hook on a readonly property only makes sense for the single initial-assignment case (typically paired with promotion). • Constructor property promotion (PHP 8.0+ syntax) can promote directly into a hooked property (PHP 8.4+) — the hook runs even on the constructor's own assignment. • Subclass hook overrides should tighten, not weaken, the parent's validation contract.
Side Effects, Recursion, and API Design
Because a set hook runs real code, it's tempting to reach for side effects — logging every assignment, firing an event, updating a cache. This works, but use it sparingly: a property assignment ($user->name = 'Ada') looks like a cheap, side-effect-free operation to every caller, and burying an HTTP call or a database write inside a set hook violates that expectation in a way that's easy to miss in code review. Keep hooks fast and local — normalization and validation are ideal; expensive I/O is not.
The most important pitfall is recursive access: inside a set hook, writing $this->name = $value; where name is the same hooked property re-triggers the same set hook, causing infinite recursion and a fatal stack-overflow-style error. This is why backed hook properties in PHP 8.4 use a distinct storage mechanism under the hood — but you must still avoid literally re-assigning through the hooked property's own name from inside its own hook. The safe pattern for a block-bodied hook that needs a real backing field is to give the underlying storage a different name (a private shadow property) or rely on the hook's own implicit backing storage rather than re-entering the public property.
From an API-design standpoint, property hooks are best thought of as a way to make previously-fake public properties (backed by informal getFoo()/setFoo() conventions) into real, enforced public properties. They shine for validation, normalization, and cheap computed values. They are a poor fit for anything that should visibly be a method call — if an operation is expensive, asynchronous, or has significant side effects, keep it an explicit method so callers can see, at the call site, that something non-trivial is happening.
• Avoid expensive I/O or far-reaching side effects inside hooks — assignment should stay conceptually cheap. • Never write $this->propertyName = ... to the same hooked property from inside its own set hook — that recurses infinitely. • Hooks are for validation, normalization, and cheap computed values — not for operations that deserve to visibly be a method call. • Think of hooks as making an honest public property, not as a place to hide business logic.
Property Hook Declaration Forms (PHP 8.4+)
All property hook syntax shown here is PHP 8.4+ — it will not parse on PHP 8.3 or earlier. set => is a single-expression hook; set { ... } is a block hook that can contain multiple statements. A get-only hook with no backing storage acts like a computed, effectively read-only property.
class User
{
// Short-arrow set hook: normalizes on every write
public string $name {
set => trim($value);
}
// Block-bodied set hook: validate, then store explicitly
public int $age {
set {
if ($value < 0) {
throw new InvalidArgumentException('age must be >= 0');
}
$this->age = $value;
}
}
// Get-only computed property: no backing storage
public string $initials {
get => strtoupper($this->name[0] ?? '');
}
// readonly + get hook: computed once, exposed read-only
public readonly string $slug {
get => strtolower(str_replace(' ', '-', $this->name));
}
}Code Examples
Every assignment to $u->name runs the set hook first. trim($value) strips the surrounding whitespace, and the trimmed result is what actually gets stored — so anything reading $u->name later, anywhere in the codebase, never has to worry about untrimmed input. Expected result: [Ada Lovelace] set => trim($value);
$name normalizes with a short-arrow hook, $email normalizes AND validates in a block hook (throwing on bad input), and $displayName is a purely computed get-only property with no backing storage of its own — it just derives from $name on every read. Together this is a realistic User class built entirely from hooks instead of getName()/setName()/getEmail()/setEmail() boilerplate. Expected result: Grace Hopper [email protected] Grace Hopper Invalid email: not-an-email if (!str_contains($normalized, '@')) { throw new InvalidArgumentException(...); }
<?php
class User
{
public string $name {
set => trim($value);
}
}
$u = new User();
$u->name = ' Ada Lovelace ';
echo "[{$u->name}]"; // [Ada Lovelace] — whitespace trimmed automatically<?php
class User
{
public string $name {
set => trim($value);
}
public string $email {
set {
$normalized = strtolower(trim($value));
if (!str_contains($normalized, '@')) {
throw new InvalidArgumentException("Invalid email: {$value}");
}
$this->email = $normalized;
}
}
// Computed: no backing storage, derived from $name on every read
public string $displayName {
get => $this->name !== '' ? $this->name : '(anonymous)';
}
}
$u = new User();
$u->name = ' Grace Hopper ';
$u->email = ' [email protected] ';
echo $u->name . "\n"; // Grace Hopper
echo $u->email . "\n"; // [email protected]
echo $u->displayName . "\n"; // Grace Hopper
try {
$u->email = 'not-an-email';
} catch (InvalidArgumentException $e) {
echo $e->getMessage() . "\n"; // Invalid email: not-an-email
}Assignment Through a set Hook
Every write funnels through the set hook before anything is stored; every read optionally funnels through a get hook on the way out.
$user->name = ' Ada ';
│
▼
┌───────────────────────────┐
│ set hook intercepts │ set => trim($value)
│ $value = ' Ada ' │
└───────────┬───────────────┘
▼
┌───────────────────────────┐
│ Validation / Normalization│ trim() strips whitespace
│ (throw if invalid) │ (or: throw InvalidArgumentException)
└───────────┬───────────────┘
▼
┌───────────────────────────┐
│ Stored value │ $this->name === 'Ada'
└───────────────────────────┘
│
▼ later read: $user->name
┌───────────────────────────┐
│ get hook (if declared) │ or: plain stored value returned
│ transforms on the way out │
└───────────────────────────┘Important Rules
Property hooks require PHP 8.4 or newer — never present them as available on 8.3 or earlier.
Never assign to the hooked property's own name from inside its own set hook — that recurses infinitely.
Keep hooks fast and side-effect-light: normalization and validation, not I/O or far-reaching side effects.
Use get-only hooks with no backing storage for purely computed, effectively read-only properties.
When overriding an inherited hook in a subclass, tighten validation rather than loosening it.
Replacing Getter/Setter Boilerplate in Domain Models
Teams migrating existing PHP 7/8.0–8.3 domain classes to PHP 8.4 commonly replace private-property-plus-getX()/setX()-pairs with hooked public properties one class at a time, starting with simple normalization cases (trimming names, lower-casing emails) before tackling validation-heavy properties. This shrinks constructors and keeps validation co-located with the property it protects, but teams are careful to keep genuinely expensive or event-driven logic (like "save the user whenever their email changes") in explicit methods rather than hidden inside a hook.
public string $name { set { $this->name = trim($value); // re-enters the SAME hook -> infinite recursion } } public string $name { set => trim($value); // short-arrow form: the result is stored directly, no recursive self-assignment }
Writing $this->name = ... inside $name's own set hook calls the set hook again, which calls it again, and so on until PHP fatally errors on stack depth. The short-arrow form (or, in a block hook, assigning through the hook's implicit backing storage rather than the public property name) avoids re-entering the hook.
In public string $name { set => trim($value); }, what does $value represent? A. The property's previous stored value B. The value currently being assigned to the property C. A reference to the whole $this object D. The property's declared type as a string
B. The value currently being assigned to the property — Inside a set hook, $value is a special variable holding the value that is currently being assigned; the hook decides what, if anything, actually gets stored.
What happens if a set hook assigns to the same hooked property's own name from inside itself, e.g. $this->name = trim($value); inside $name's own set hook? A. It stores the value normally, exactly like the short-arrow form B. PHP silently ignores the inner assignment C. It re-triggers the same set hook, causing infinite recursion D. It throws a TypeError at compile time
C. It re-triggers the same set hook, causing infinite recursion — Assigning to a hooked property's own name from inside its own set hook re-enters that same hook, which re-enters it again, recursing until PHP fatally errors — this is the classic property-hook recursion pitfall.
What minimum PHP version is required to use property hooks? A. PHP 8.0 B. PHP 8.2 C. PHP 8.3 D. PHP 8.4
D. PHP 8.4 — Property hooks were introduced in PHP 8.4 and are not available on any earlier version.
Add a hooked $price property (float, PHP 8.4+ hook syntax) to a Product class: it should reject negative values by throwing InvalidArgumentException, and round the stored value to 2 decimal places.
public float $price { set { if ($value < 0) { throw new InvalidArgumentException('price must be >= 0'); } $this->price = round($value, 2); } }
Use a block-bodied set hook so you can both validate (throw) and transform (round) before storing.