7 min read

PHP 8.4 Property Hooks: Cleaner Object APIs Without Magic Methods

PHP 8.4 property hooks let you intercept property reads and writes directly — no more boilerplate getters, setters, or fragile magic methods.

Featured image for "PHP 8.4 Property Hooks: Cleaner Object APIs Without Magic Methods"

PHP 8.4 shipped in November 2024 and brought with it one of the most significant object-oriented additions to the language in years: property hooks. If you have been writing PHP classes long enough, you have had the internal debate about whether to use public properties for simplicity, or private properties with explicit getPropertyName() / setPropertyName() methods for control. Property hooks dissolve that trade-off. You get direct property access syntax for callers, with full interception logic on the class side — no boilerplate, no magic methods, and no interface breakage when requirements change.

The Problem Property Hooks Solve

The classic PHP pattern for controlled property access looks like this:

class Product {
    private float $price = 0.0;

    public function getPrice(): float
    {
        return $this->price;
    }

    public function setPrice(float $value): void
    {
        if ($value < 0) {
            throw new \ValueError("Price cannot be negative.");
        }
        $this->price = $value;
    }
}

This is fine. But it means callers use $product->getPrice() and $product->setPrice(9.99) instead of just $product->price. That distinction becomes awkward in templates, in data-mapping code, and when you refactor a class that started as a plain data holder. The alternative — a bare public float $price — gives you direct access but no place to put validation or transformation logic without breaking every call site.

PHP’s __get() and __set() magic methods technically offer an escape hatch, but they are a maintenance trap. They apply to all properties, they bypass static analysis, and IDE support for them is inconsistent at best.

Property hooks give you a per-property solution that static analysis tools, IDEs, and the PHP engine itself all understand natively.

The Syntax

A hook is a block of code attached directly to a property declaration, using get and set keywords inside curly braces:

class Product {
    public float $price {
        set(float $value) {
            if ($value < 0) {
                throw new \ValueError("Price cannot be negative.");
            }
            $this->price = $value;
        }
    }
}

$product = new Product();
$product->price = 49.99;  // triggers the set hook
echo $product->price;     // 49.99 — no hook, reads backing store directly

Inside a set hook, assigning to $this->price writes to the backing store — the actual stored value — without re-triggering the hook. This avoids infinite recursion. Similarly, reading $this->price inside a get hook reads the backing store directly.

The hook parameter ($value above) can have its own type declaration independent of the property type, although narrowing the type is the most common use case. If you omit the parameter list, the property’s declared type is used automatically.

Both short (arrow) and long (block) forms are supported:

class User {
    public string $email {
        // Short form — expression result is implicitly returned
        get => strtolower($this->email);

        // Long form — full block, explicit assignment required
        set(string $value) {
            $this->email = strtolower(trim($value));
        }
    }
}

Virtual Properties

A property with only a get hook and no set hook has no backing store at all. It becomes a virtual property — read-only and computed entirely on the fly:

class User {
    public string $firstName = '';
    public string $lastName  = '';

    // No backing store — computed from other properties
    public string $fullName {
        get => "$this->firstName $this->lastName";
    }
}

$user = new User();
$user->firstName = 'Ada';
$user->lastName  = 'Lovelace';

echo $user->fullName; // "Ada Lovelace"
$user->fullName = 'Something'; // Error: property has no set hook

Virtual properties replace a common getter-only pattern without requiring a method call. They work naturally with template engines, serializers, and any code that expects to access data via property syntax.

Practical Pattern: Value Objects with Validation

Property hooks shine in value objects where each property needs its own invariant:

class Money {
    public int $amount {
        set(int $value) {
            if ($value < 0) {
                throw new \DomainException("Amount cannot be negative.");
            }
            $this->amount = $value;
        }
    }

    public string $currency {
        set(string $value) {
            $value = strtoupper(trim($value));
            if (strlen($value) !== 3) {
                throw new \DomainException("Currency must be a 3-letter ISO code.");
            }
            $this->currency = $value;
        }
    }
}

$price = new Money();
$price->amount   = 1999;
$price->currency = 'usd'; // stored as 'USD'

Compare this to the pre-8.4 equivalent: either four methods with matching doc blocks, or a constructor-only readonly value object that requires you to instantiate a new object just to change one field. With hooks, the object stays mutable where needed and safe everywhere.

Practical Pattern: Lazy Caching

Get hooks are useful for computing expensive values exactly once and caching the result in the backing store:

class Report {
    private array $rawData;

    public array $processedRows {
        get {
            if (empty($this->processedRows)) {
                $this->processedRows = $this->transformData($this->rawData);
            }
            return $this->processedRows;
        }
    }

    private function transformData(array $data): array
    {
        // expensive transformation...
        return array_map(fn($row) => array_map('trim', $row), $data);
    }
}

The first read triggers the transformation and caches the result. Subsequent reads return the cached value. This replaces the null-initialized private property pattern that was common before, without exposing internal caching logic to callers.

Hooks in Interfaces and Abstract Classes

Property hooks can be declared as abstract requirements in interfaces and abstract classes:

interface HasFullName {
    public string $fullName { get; }
}

interface Auditable {
    public \DateTimeImmutable $createdAt { get; }
    public \DateTimeImmutable $updatedAt { get; set; }
}

Implementing classes must satisfy the hook contract. An interface declaring { get; } means any class implementing it must make the property readable — whether via a get hook, a virtual property, or simply a plain public property (which satisfies a get requirement automatically). This allows interfaces to express read-only vs read-write contracts at the property level, something that was previously only achievable through methods.

PHP 8.4 also introduced asymmetric visibility, which is worth mentioning alongside hooks because the two are often confused. Asymmetric visibility lets you declare a property as publicly readable but privately (or protectedly) writable, without any hook logic:

class Order {
    public private(set) string $status = 'pending';

    public function confirm(): void
    {
        $this->status = 'confirmed'; // allowed: inside the class
    }
}

$order = new Order();
echo $order->status;    // 'pending' — allowed: public read
$order->status = 'x';  // Error: private set

Use asymmetric visibility when you simply want to restrict write access without needing transformation or validation logic. Use property hooks when you need to intercept the read or write itself.

What About PHPStan and Psalm?

As of their releases covering PHP 8.4, both PHPStan and Psalm understand property hooks. A get hook’s return type is checked against the property type. A set hook’s parameter type is enforced at call sites. Virtual properties are correctly flagged as read-only by static analysis. If you are running PHPStan level 8 or higher, you will catch type mismatches in hook logic the same way you catch them anywhere else.

Rector also has rules for PHP 8.4 modernization, including a rule that can suggest converting simple getter/setter pairs to property hooks when the migration is safe. It will not convert every pattern automatically, but it is a useful starting point when upgrading a codebase.

Should You Be Using This Now?

PHP 8.4 is the current stable release, and most modern hosting environments (Forge, Vapor, Laravel Cloud, Hetzner with PHP-FPM) support it. If your composer.json requires "php": "^8.1" and you are not ready to bump that constraint, you cannot use hooks in that package. But for application code — services, value objects, DTOs, Eloquent model observers — there is no reason to wait.

The migration path is additive. You can introduce hooks one class at a time without touching call sites. That is the key benefit: a caller doing $user->email = '[email protected]' has no idea whether email is a plain property, a hooked property, or a virtual one. The interface is identical. Your class gains new superpowers invisibly.

Resources