PHP 8.4 Property Hooks: Practical Examples and When to Use Them

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 readonly combination. A property cannot be both readonly and have hooks. Use private(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(). Calling unset() on a hooked property always throws an error.
  • No indirect array modification. $obj->tags[] = 'new' does not work if $tags has 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 = $value from inside the set hook 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(), and get_object_vars() invoke the get hook.

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 in json_encode vs serialize behaviour 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_requirement in composer.json before 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->propertyName inside 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 readonly and 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.

Add new comment

Restricted HTML

  • Allowed HTML tags: <a href hreflang> <em> <strong> <cite> <blockquote cite> <code> <ul type> <ol start type> <li> <dl> <dt> <dd> <h2 id> <h3 id> <h4 id> <h5 id> <h6 id>
  • Lines and paragraphs break automatically.
  • Web page addresses and email addresses turn into links automatically.
Please share this article on your favorite website or platform.