Property Hooks in PHP 8.4: Getters and Setters Without Boilerplate
AI generated
<?php
8.4
PHP · Property Hooks · PHP 8.4
Property Hooks in PHP 8.4
Getters and Setters Without Boilerplate

For years, PHP developers wrote a pair of getX() and setX() methods for every validated or computed property. Property Hooks move that logic directly into the property declaration itself, with get and set hooks that behave like plain fields for callers while keeping full control over reads and writes.

14 min read Property Hooks · get/set · Virtual Properties PHP 8.4

1. The Boilerplate Problem of Classic Getters/Setters

In practically every object-oriented PHP codebase you find the same pattern: a private property, a public getName() method that returns the value unchanged, and a setName() method that runs a validation before assigning it. For a class with ten properties, that quickly means twenty extra methods, most of which do nothing more than execute a single line of code. This boilerplate is not wrong, but it buries the actual business logic under ceremony that has to be rewritten for every new property.

The real problem is not the typing effort but maintainability. When a validation rule changes, it has to be updated in the setter method, and developers need to know that this method exists instead of assigning the property directly. In many projects this discipline is not consistently upheld: somewhere in the code, a legacy method reaches directly into a public property and bypasses any validation, because before PHP 8.4 there was no way to attach behavior to reading and writing a property itself without hiding it completely behind methods.

Property Hooks resolve exactly this tension. Instead of hiding a property and exposing it through methods, the property stays visible as a property and still gets behavior attached to it. The access $user->email = 'foo@bar.com' looks syntactically like a plain assignment, but can validate, normalize, or even compute the entire value under the hood, without the caller noticing anything or having to call a method. That not only cuts down the line count, it removes the temptation to accidentally set a property unchecked, because there simply is no unchecked access path left.

2. Property Hook Syntax: get and set

The basic syntax of Property Hooks adds a block in curly braces to the property declaration that can contain a get hook and a set hook. In the short arrow form it looks like this: public string $name { get => strtoupper($this->name); }. The expression after => is evaluated on every read access to the property. For more complex logic there is the block form with curly braces and an explicit return statement, which can contain any number of statements before the value is returned.

The set hook works symmetrically: it receives an implicit parameter named value representing the assigned value, and can check or transform it before the actual storage happens. Important detail: inside a hook, accessing $this->propertyName refers to the so-called backing field, meaning the value actually stored, not recursively to the hook itself. PHP recognizes this distinction automatically from the context, so get => $this->name works without any additional convention and without triggering an infinite loop.

A property can also define only one of the two hooks. A property with only a get hook effectively behaves like a computed, read-only property. A property with only a set hook keeps its normal read behavior but validates every assignment. This flexibility lets you implement exactly the level of control a given property actually needs, instead of covering both directions across the board.


declare(strict_types=1);

final class Product
{
    public function __construct(
        private string $name,
        private float $priceNet,
    ) {
    }

    // Short-form get hook: normalizes on every read access
    public string $name {
        get => trim($this->name);
    }

    // Block-form set hook: validates and normalizes before storing
    public float $priceNet {
        set {
            if ($value < 0.0) {
                throw new InvalidArgumentException('Price must not be negative.');
            }
            $this->priceNet = round($value, 2);
        }
    }
}

$product = new Product('  Keyboard  ', 49.995);
echo $product->name;      // "Keyboard" - trimmed by the get hook
echo $product->priceNet;  // 50.0 - rounded by the set hook

3. Virtual Properties Without a Backing Field

A particularly useful application of Property Hooks is virtual properties, meaning properties that have no stored value of their own at all. As soon as a property defines only a get hook and, inside that hook, does not reference a backing field but computes the value entirely from other properties, no storage exists for that property inside the object. In that case PHP does not allocate a backing field, which saves memory and, at the same time, rules out returning a stale, cached value.

A typical example is a fullName property composed from firstName and lastName. Instead of maintaining a separate instance variable that would need to stay in sync every time the first or last name changes, the get hook recomputes the value on every access. That eliminates an entire class of synchronization bugs where a derived property is forgotten after a change to its source properties.

Virtual properties also suit values derived from external sources, such as a formatted or display value computed from an internal raw value. The important distinction: a virtual property with a pure get hook is inherently read-only. Attempting to assign it a value causes PHP to throw an error, since there is no set hook and no backing field to write into. This clarity is an advantage over classic getters, where the absence of a write path is not visible from the signature but only inferred from the absence of a setter method.


declare(strict_types=1);

final class Customer
{
    public function __construct(
        private string $firstName,
        private string $lastName,
    ) {
    }

    // Virtual property: no backing field, computed on every access
    public string $fullName {
        get => sprintf('%s %s', $this->firstName, $this->lastName);
    }

    // Virtual, derived, read-only presentation value
    public string $initials {
        get => strtoupper($this->firstName[0] . $this->lastName[0]);
    }
}

$customer = new Customer('Ada', 'Lovelace');
echo $customer->fullName;  // "Ada Lovelace" - computed, never stored
echo $customer->initials; // "AL"

// $customer->fullName = 'New Name'; // Error: no set hook defined

4. Validation and Transformation in the set Hook

Arguably the most common use case for Property Hooks in practice is validating input data directly at assignment time. Instead of declaring a private property and writing a separate setEmail() method that handles validation, the check moves directly into the property's set hook. Every assignment, whether through constructor property promotion or later in the code, automatically goes through the same validation logic, without any developer needing to remember to use the correct method instead of a direct assignment.

Besides pure validation that throws an exception on an invalid value, the set hook is equally well suited for normalization. A phone number field can automatically strip whitespace and hyphens on assignment, an email field can be lowercased before the value lands in the backing field. This transformation happens transparently for the caller: it assigns a raw value and later reads back the normalized value, without ever calling a separate normalization function.

An important detail is the ability to declare the type of the implicit value parameter in the set hook more broadly or differently than the property's own declared type, for instance to additionally accept Stringable objects alongside a string and normalize them to a plain string inside the hook. That allows a more permissive, developer-friendly API on the outside while internally only ever storing the strictly typed, validated value. This combination of leniency on input and strictness on stored state simply was not expressible with classic property declarations without an extra setter method.


declare(strict_types=1);

final class EmailAddress
{
    public string $value {
        set {
            $normalized = strtolower(trim($value));
            if (!filter_var($normalized, FILTER_VALIDATE_EMAIL)) {
                throw new InvalidArgumentException(
                    sprintf('"%s" is not a valid email address.', $value)
                );
            }
            $this->value = $normalized;
        }
    }

    public function __construct(string $value)
    {
        $this->value = $value; // Runs through the set hook above
    }
}

$email = new EmailAddress('  User@Example.COM  ');
echo $email->value; // "user@example.com" - validated and normalized

5. Computed Values in the get Hook

While virtual properties do without a backing field entirely, there is a second, equally common case: a property has a backing field, but the get hook transforms the stored raw value on every read access. One example is an internal amount stored in cents that is returned as a formatted euro amount with two decimal places on read, while the internal representation keeps using integer cent values to avoid floating-point rounding errors.

An important trap with computed get hooks concerns performance and caching. Since the hook runs on every single read access, an expensive computation, such as heavy string processing or even a database call, becomes correspondingly costly inside a loop with many read accesses. Property Hooks provide no built-in memoization, the result is not automatically cached. Anyone who wants to cache the result has to implement that explicitly, for example with an additional nullable property that gets populated on first access.

A second point that is easy to miss: a get hook that depends on other, mutable properties must account for that dependency freshly on every evaluation. If one of the source properties changes between two read accesses, the get hook automatically returns the current, freshly computed value on the next access. In most cases that is exactly the desired behavior, but it can surprise a developer who unconsciously assumes a one-time computed, immutable value, even though the property is re-evaluated on every single access. A typical example is an internal cent amount with a formattedTotal hook that gets reformatted on every read: without manual caching through an additional private property, number_format() runs unnecessarily often on frequent reads, which can become noticeable inside rendering loops with many list items.

6. Property Hooks in Interfaces and Abstract Classes

Property Hooks can be used not only in concrete classes but also declared as a contract in interfaces. An interface can require a property with { get; } without an implementation, ensuring that every implementing class provides a readable property of that name and type, regardless of whether the concrete class implements an actual hook or uses a plain, unhooked property that automatically satisfies the same contract.

This is a meaningful difference from classic getters in interfaces: instead of requiring public function getName(): string;, the interface directly declares public string $name { get; }. For the caller, access changes noticeably: it reads $object->name instead of calling $object->getName(), which, especially combined with value objects and data transfer objects, produces noticeably more readable code without losing any of the guarantees an interface provides.

In abstract classes this principle can be extended further: an abstract class can declare an abstract get hook that concrete subclasses must implement, while providing a concrete set hook itself with shared validation logic for all subclasses. This split lets you centralize shared behavior once while still enforcing subclass-specific behavior, much like abstract methods, just applied at the level of individual properties rather than whole methods.


declare(strict_types=1);

interface HasDisplayLabel
{
    // Contract: any implementing class must expose a readable label
    public string $displayLabel { get; }
}

abstract class AbstractStatus implements HasDisplayLabel
{
    protected string $code = '';

    // Shared validation for all subclasses, enforced through the set hook
    public string $code {
        set {
            if ($value === '') {
                throw new InvalidArgumentException('Status code must not be empty.');
            }
            $this->code = strtoupper($value);
        }
    }

    // Forces every concrete subclass to define its own label computation
    abstract public string $displayLabel { get; }
}

final class OrderStatus extends AbstractStatus
{
    public string $displayLabel {
        get => match ($this->code) {
            'NEW' => 'Newly received',
            'SHIPPED' => 'Shipped',
            default => 'Unknown',
        };
    }
}

7. Inheritance: Overriding and Extending Hooks

When inheriting from classes with Property Hooks, a subclass can override an inherited hook to add extra behavior without fully duplicating the base logic. The key to this is access via parent::$propertyName, which lets a subclass reach specifically into the parent class's backing field while the overriding hook adds its own additional checks or transformations. Conceptually this mirrors the parent::method() call for overridden methods, just applied to property access.

Regarding covariance, Property Hooks follow rules similar to method return types: an overriding get hook is allowed to return a narrower, more specific type than the one originally declared in the parent class, as long as that type remains compatible with the parent type. For the set hook the opposite contravariance rule applies: the accepted value type must not become narrower when overriding, otherwise the base class contract that any value valid there can also be assigned in the subclass would be violated.

A practical example is a base class Money that, in its set hook, only checks that a value is not negative, while a subclass ForeignMoney additionally adds a currency validation but still uses parent::$amount = $value; to reuse the base check and avoid code duplication. This combination of reuse and targeted extension was also possible with classic setter methods via a call to parent::setAmount($value), but feels more consistent with Property Hooks because both the base and the subclass use the same access syntax through the property itself.


declare(strict_types=1);

class Money
{
    protected float $amount = 0.0;

    public float $amount {
        set {
            if ($value < 0.0) {
                throw new InvalidArgumentException('Amount must not be negative.');
            }
            $this->amount = $value;
        }
    }
}

final class ForeignMoney extends Money
{
    public function __construct(
        private readonly string $currency,
    ) {
    }

    // Extends the parent hook instead of duplicating the base validation
    public float $amount {
        set {
            if (!in_array($this->currency, ['USD', 'GBP', 'CHF'], true)) {
                throw new InvalidArgumentException('Unsupported currency.');
            }
            parent::$amount = $value; // Reuses the base class validation
        }
    }
}

$payment = new ForeignMoney('USD');
$payment->amount = 42.50; // Runs currency check, then the inherited negativity check

8. Property Hooks vs. Classic Getter/Setter Methods

Performance-wise there is no practically relevant difference between a get hook and a classic getter method: both are ultimately executed by the PHP engine as a method call with comparable overhead, a Property Hook is not a pure field-access optimization at the bytecode level. The real advantage lies not in execution speed but in the readability of calling code, and in the fact that call sites written as $obj->prop instead of $obj->getProp() are noticeably more compact and read more naturally in templates or expressions.

Migrating existing classes with an established getX()/setX() API raises a practical compatibility question: an already published library cannot simply replace its public methods with Property Hooks without breaking the existing API, since callers still expect ->getName(). One viable path is to introduce Property Hooks alongside the existing method, where the method internally just reads or writes the property, so new callers can use the more compact property syntax while old callers keep working unchanged.

It is also worth distinguishing Property Hooks from two related PHP 8.4 features: Asymmetric Visibility (public private(set) string $name) governs who may write a property at all, without inserting behavior such as validation, and Readonly Properties prevent any change after initialization entirely. Property Hooks differ from both because they do not govern visibility or mutability, but determine the actual behavior on every read or write access. All three features can be combined in PHP 8.4, but they each address a different question.

Scenario Classic Approach (Getter/Setter Method) Property Hook Advantage
Validation on assignment setEmail(string $v) called manually set { ...; $this->x = $v; } Direct assignment can no longer bypass it
Computed value getFullName(): string get => "$first $last" Accessed like a plain field
Virtual property Method with no backing field needed get-only, no storage No dead storage inside the object
Contract in an interface public function getX(): string; public string $x { get; } Shorter, clearer contract
Extension in a subclass parent::setX($v) parent::$x = $v; Consistent access syntax

9. Limits and Pitfalls

An important limitation of Property Hooks is that one hook cannot directly access another hook of the same or a different property to reuse its logic. There is no syntax to "call the get hook of another property" other than the regular property access itself. Anyone who wants to share logic between multiple hooks has to extract it into a private method and call it from both hooks, instead of expecting a direct hook-to-hook reference, which simply does not exist in PHP 8.4.

Recursion is a real danger if a get hook accidentally references itself instead of the backing field. As long as the hook accesses only its own backing field via $this->propertyName, PHP behaves correctly and no infinite loop occurs. But if another hook is accidentally invoked that in turn reads the first one, a circular dependency can form that PHP does not detect automatically at compile time, and it only surfaces at runtime as a stack overflow error.

There are also special considerations when interacting with serialization and debugging functions. var_dump() and print_r() do not show a stored raw value for virtual properties without a backing field, and may either trigger the get hook or mark the property as virtual, depending on the PHP version and the debugging tools used. With serialize() and the Serializable interface, only backing fields that actually exist get serialized, virtual properties are recomputed from the available fields on deserialization and therefore do not need separate handling, as long as their dependencies are themselves serialized. A simple example illustrates the backing-field rule: a property public int $value { get => $this->value; set { $this->value += $value; } } correctly accesses its own backing field in both hooks and creates no recursion, as long as no other hook is interposed that in turn triggers the same access.

10. Summary

Property Hooks solve a problem PHP developers have worked around with manual boilerplate for years: validation, normalization, and computed values can now live directly in the property declaration instead of being hidden behind separate getX()/setX() methods. The get hook allows both transformed read access to an existing backing field and fully virtual, unstored properties. The set hook centralizes validation and transformation in exactly one place that cannot be bypassed.

For larger codebases, the possibilities within interfaces and inheritance are especially relevant: an interface can define a clear property contract with { get; }, and subclasses can reach into base behavior with parent::$prop instead of duplicating it. At the same time, real limits remain, such as the lack of direct hook-to-hook communication and the need to implement caching yourself for expensive get hooks. Anyone aware of these limits can deploy Property Hooks precisely where they offer real value over classic getters and setters.

Property Hooks in PHP 8.4 - The Key Takeaways

Syntax

public T $x { get => ...; set { ...; $this->x = $value; } }, short or block form per hook.

Virtual Properties

A pure get hook without backing field access produces a computed, read-only property with no storage.

Inheritance

parent::$prop reaches into the parent class's backing field, hooks can be extended in a targeted way instead of duplicated.

Limits

No automatic caching in the get hook, no direct access to other hooks, risk of recursion on incorrect field access.

11. FAQ: Property Hooks in PHP 8.4

1What are Property Hooks in PHP 8.4?
Get and set blocks defined directly inside the property declaration, allowing validation, transformation, or computation of a value without separate getX()/setX() methods.
2How do Property Hooks differ from classic getters and setters?
Access looks like a plain field, performance is comparable, but the syntax is more compact and direct assignment can no longer bypass validation.
3What is a virtual property?
A property with only a get hook, fully computed from other properties, with no backing field of its own and no storage.
4How do I access the stored value inside a hook?
Via $this->propertyName inside the hook itself, which PHP recognizes as access to the backing field, without recursing into the hook.
5Can Property Hooks be declared in interfaces?
Yes, with public string $name { get; } as a contract that implementing classes must satisfy, similar to an abstract getter method.
6How can a subclass extend an inherited Property Hook?
With parent::$propertyName access to the parent class's backing field, extended with additional logic inside the overriding hook.
7Do Property Hooks automatically cache computed values?
No, a get hook runs fresh on every access. Caching must be implemented manually, for example with an additional private property.
8What distinguishes Property Hooks from Asymmetric Visibility?
Asymmetric Visibility only governs who may write, without behavior. Property Hooks govern the actual behavior on read and write access, both are combinable.
9What distinguishes Property Hooks from Readonly Properties?
Readonly prevents any change after initialization. Property Hooks still allow assignments but validate or transform them in the set hook, both are combinable.
10Can one hook access another hook of the same class?
Not directly. There is no syntax to call another hook specifically. Shared logic should be extracted into a private method called from both hooks.

Mironsoft

PHP 8.4 Code Review and Modernization

Modernizing existing PHP code with Property Hooks?

We review existing getter/setter boilerplate in your codebase, identify good candidates for Property Hooks, and guide the step-by-step migration to PHP 8.4, without breaking existing APIs.

Code Review

Analysis of existing getter/setter patterns and assessment of migration potential to Property Hooks

Modernization

Step-by-step introduction of Property Hooks, Asymmetric Visibility and Readonly Properties in PHP 8.4

PHPStan Coverage

Static analysis at level 5+ after migration, so type safety and contracts remain intact