Property hooks are the headline feature of PHP 8.4. They let you attach get and set logic directly to a property declaration, removing the need for hand-written getter and setter methods in many common situations. IDEs and static analysis tools understand them natively — unlike magic __get/__set pairs, which are effectively invisible to tooling.
This article covers the full syntax, explains the difference between backed and virtual properties, shows where property hooks genuinely simplify your code, and describes the edge cases and restrictions you need to know before adopting them.
The Problem Property Hooks Solve
Before PHP 8.4, adding validation or transformation logic to a property meant converting it to private and writing a pair of accessor methods:
class User
{
private string $email;
public function getEmail(): string
{
return $this->email;
}
public function setEmail(string $email): void
{
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
throw new \InvalidArgumentException("Invalid email: $email");
}
$this->email = strtolower($email);
}
}
Callers must use $user->getEmail() and $user->setEmail() — you lose the clean property access syntax. Property hooks restore that syntax while keeping the logic:
class User
{
public string $email {
set(string $email) {
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
throw new \InvalidArgumentException("Invalid email: $email");
}
$this->email = strtolower($email);
}
}
}
$user = new User();
$user->email = 'Lars@Example.COM';
echo $user->email; // lars@example.com
Callers write $user->email = '...' and $user->email as normal. The hook logic fires automatically.
Syntax Reference
The get Hook
A get hook intercepts reads and must return a value matching the property type:
class Temperature
{
public float $celsius = 0.0;
// Computed property — no separate $fahrenheit backing value stored
public float $fahrenheit {
get => $this->celsius * 9/5 + 32;
}
}
$t = new Temperature();
$t->celsius = 100.0;
echo $t->fahrenheit; // 212
The set Hook
A set hook intercepts writes. The incoming value is available as $value by default, or you can name the parameter explicitly:
class Product
{
// Implicit $value parameter — type inferred from the property type
public string $slug {
set => strtolower(preg_replace('/[^a-z0-9]+/i', '-', $value));
}
// Explicit parameter name and type
public int $stock {
set(int $quantity) {
if ($quantity < 0) {
throw new \RangeException('Stock cannot be negative');
}
$this->stock = $quantity;
}
}
}
The short set => expression form automatically assigns the expression result to the backing property. The long set { ... } form requires you to assign $this->propertyName yourself if you want the value stored.
Both Hooks Together
class Profile
{
public string $username {
get => strtolower($this->username);
set => trim($value);
}
}
Backed vs Virtual Properties
A backed property has an underlying stored value. Your hook accesses it via $this->propertyName. Most properties with hooks are backed.
A virtual property has no backing store — it exists only through its hooks and never accesses $this->propertyName. Virtual properties cannot have default values and must implement both hooks if both read and write access are needed:
class FullName
{
public string $first = '';
public string $last = '';
// Virtual — no backing storage for $fullName
public string $fullName {
get => $this->first . ' ' . $this->last;
set {
[$this->first, $this->last] = explode(' ', $value, 2);
}
}
}
$name = new FullName();
$name->fullName = 'Lars Nielsen';
echo $name->first; // Lars
echo $name->last; // Nielsen
echo $name->fullName; // Lars Nielsen
Virtual properties are excluded from serialize() — only backed properties are serialised. An attempt to deserialise a value into a virtual property throws an error.
Property Hooks in Interfaces and Abstract Classes
One of the most powerful aspects of property hooks is that interfaces can declare property requirements with access constraints — eliminating a whole class of boilerplate getter methods:
interface HasTimestamps
{
// Implementors must provide a readable $createdAt — no setter required
public \DateTimeImmutable $createdAt { get; }
// Both readable and writable
public ?\DateTimeImmutable $updatedAt { get; set; }
}
Implementing classes satisfy these requirements either with a plain public property (which is both readable and writable) or with a hooked property that provides the required access:
class Article implements HasTimestamps
{
// Satisfies { get; } — plain public property is readable
public \DateTimeImmutable $createdAt;
// Satisfies { get; set; } — with a normalisation hook on write
public ?\DateTimeImmutable $updatedAt {
set(?\DateTimeImmutable $dt) {
$this->updatedAt = $dt;
// Side effect: update a dirty flag
$this->isDirty = true;
}
}
private bool $isDirty = false;
}
Abstract classes can declare abstract properties to force subclasses to provide them:
abstract class BaseEntity
{
abstract public int $id { get; }
}
Constructor Promotion with Hooks
Property hooks work with promoted constructor parameters, which is especially useful for value objects:
class Money
{
public function __construct(
public readonly int $amount,
public string $currency {
set => strtoupper($value);
}
) {}
}
$price = new Money(1000, 'usd');
echo $price->currency; // USD
Inheritance and Overriding Hooks
Child classes can add hooks to inherited plain properties, override individual hooks from a parent, or call the parent hook explicitly:
class Base
{
public string $name {
set => trim($value);
}
}
class Child extends Base
{
public string $name {
set {
// Call the parent set hook, then do additional work
parent::$name::set($value);
$this->nameWasSet = true;
}
}
public bool $nameWasSet = false;
}
Declare a hook final to prevent child classes from overriding it:
class Immutable
{
public string $id {
final set => throw new \LogicException('ID is immutable');
}
}
What Property Hooks Cannot Do
Several restrictions catch developers by surprise:
- No
readonlycombination. A property cannot be bothreadonlyand have hooks. Useprivate(set)(asymmetric visibility, also new in PHP 8.4) if you want write-once semantics with hooks. - No multiple declarations. You cannot declare multiple properties on the same line if any of them have hooks:
public string $a, $b { get => ... }is a syntax error. - No
unset(). Callingunset()on a hooked property always throws an error. - No indirect array modification.
$obj->tags[] = 'new'does not work if$tagshas a set hook — PHP cannot call the hook with the modified array before it has been built. Fetch, modify, and re-assign instead. - No recursion. Accessing a hooked property from within its own hook causes an error. This prevents accidental infinite loops but catches the common mistake of writing
$this->name = $valuefrom inside thesethook of a virtual property. - Serialisation is hook-aware.
var_dump(),serialize(), and array casting bypass hooks and access the raw backing value.json_encode(),var_export(), andget_object_vars()invoke thegethook.
Practical Drupal/PHP Application: A Typed Value Object
Property hooks shine in value objects and domain model classes — a pattern common in Drupal custom modules and standalone PHP applications:
<?php
declare(strict_types=1);
namespace App\Domain;
final class EmailAddress
{
public string $value {
set(string $email) {
$normalised = strtolower(trim($email));
if (!filter_var($normalised, FILTER_VALIDATE_EMAIL)) {
throw new \InvalidArgumentException(
"\"$email\" is not a valid email address."
);
}
$this->value = $normalised;
}
}
// Expose the domain part as a computed read-only virtual property
public string $domain {
get => substr($this->value, strpos($this->value, '@') + 1);
}
public function __construct(string $email)
{
$this->value = $email; // triggers the set hook
}
}
$email = new EmailAddress('Lars@Example.COM');
echo $email->value; // lars@example.com
echo $email->domain; // example.com
new EmailAddress('not-an-email'); // throws InvalidArgumentException
Compare this to the pre-8.4 version: you would need a private $value, a constructor calling $this->setValue(), a getValue() method, a getDomain() method, and probably a __toString(). The hooks version is half the code and reads like a typed data class from any modern language.
Reflection API Access
PHP 8.4 adds new ReflectionProperty methods for inspecting hooked properties programmatically:
$ref = new ReflectionProperty(EmailAddress::class, 'value');
$ref->isVirtual(); // false — has backing storage
$ref->getHooks(); // ['set' => ReflectionMethod, ...]
$ref->getHook(\PropertyHookType::Set); // ReflectionMethod or null
// Bypass hooks to read/write raw backing value (useful in serialisers)
$instance = new EmailAddress('a@b.com');
$raw = $ref->getRawValue($instance);
$ref->setRawValue($instance, 'raw@value.com');
The getRawValue() and setRawValue() methods are the intended escape hatch for serialisers that need to bypass hook logic — use them sparingly.
When to Use Property Hooks vs Getters/Setters
Property hooks are not a wholesale replacement for accessor methods. Use them when:
- You want callers to use property syntax (
$obj->prop = x,$obj->prop) rather than method calls. - The logic is simple enough to live on the property — validation, normalisation, or a computed derivative.
- You are defining an interface contract and want to express "this property must be readable" without mandating a method name.
- You are writing a value object or DTO where encapsulation and clean syntax both matter.
Stick with traditional getter/setter methods when:
- The logic requires multiple parameters (set hooks take exactly one).
- You need to return a value from the setter (e.g., a fluent interface).
- The property needs to be serialised via
serialize()and the hook logic must also run during deserialisation — the asymmetry injson_encodevsserializebehaviour can surprise you. - You are working in an existing codebase with a strong convention of
get*()/set*()methods — inconsistency is worse than not using the feature.
Summary Checklist
- Property hooks require PHP 8.4+. Check
core_version_requirementincomposer.jsonbefore adopting them in shared libraries. - Use the short arrow form (
get =>,set =>) for single-expression hooks; use the block form for multi-statement logic. - Backed properties access
$this->propertyNameinside hooks; virtual properties do not and cannot have default values. - Interface-declared properties with
{ get; }eliminate the need for boilerplate getter methods in interface definitions. - Constructor promotion works with hooks — useful for value object patterns.
- Avoid mixing
readonlyand hooks on the same property; use asymmetric visibility (public private(set)) instead. - Be aware of the serialisation asymmetry:
serialize()bypasses hooks,json_encode()does not. - Use
ReflectionProperty::getRawValue()in custom serialisers rather than bypassing hooks via magic methods.