A decorative image promoting PHP 8.4 property hooks
October 17, 2024

A Guide to PHP 8.4 Property Hooks

PHP Development

PHP 8.4 releases on November 21, 2024, and it brings with it an expansive set of exciting new features and changes. One of the most prominent is the introduction of property hooks, a feature that aims to simplify working with PHP class properties.

In this blog, I walk through the background and evolution of PHP class properties and property hooks. I then provide an in-depth discussion of what property hooks are and why their introduction in PHP 8.4 represents an exciting leap forward for the PHP language. Finally, I provide an overview of the various features and capabilities of property hooks, with recommendations on what to use and what to avoid when using them in your PHP applications.

Back to top

PHP 8.4 Property Hooks: Background

PHP class properties have been part of the PHP object model since the very beginning, providing a standard mechanism for encapsulating state within a class. They are so fundamental that the object model has even allowed defining them dynamically, allowing consumers to add their own state mechanisms if they want.

Many feel this is a bad idea.

By allowing dynamic properties, we provide no guarantees to users about the property type, or even that the value is valid. As such, there have been a number of patterns that have evolved over the years to address it.

Most recently, in PHP 8.2, the project deprecated the ability to create dynamic properties on the fly. The feature still exists, but you need to add the #[\AllowDynamicProperties] attribute to your class to remove the deprecation warning. In a future major version (likely 9, potentially 10), using the attribute will be the only way to enable this feature.

Before that, however, we had a few other mechanisms.

Using Magic Methods

If you wanted to allow dynamic properties, but enforce naming conventions or value validations, PHP has provided a number of magic methods for defining these interactions:

  • __get(string $name): mixed
  • __set(string $name, mixed $value): void
  • __isset(string $name): bool
  • __unset(string $name): void

These could be provided as a set or individually to allow read and/or write access to undefined or non-public properties, and as a set, provide property overloading. Consumers can use standard property access with this approach. However, there is a significant downside: because the properties are not defined explicitly, there's no way to know what properties are available, or the types associated with each. There are ways to provide hints that your IDE can use to discover this information, but:

  • If you provide that information, you could have likely defined the property in the first place.
  • If you are enabling dynamic properties, where not all of them will be known at the time of definition, you cannot provide hints.
  • You generally don't want to define these magic methods as part of an interface, because you cannot lock them down to behavior.

Still, this approach does solve one significant problem: if you need to validate or normalize a value when setting it on a property, you can encapsulate that in the __set() method.

PHP Getter Setter Methods

Another alternative is to eschew public property access altogether, and instead encapsulate properties behind methods. This approach generally leads to getters and setters:

public function getUsername(): string { }
public function setUsername(string $username): void { }
// and sometimes
public function unsetUsername(): void { }


When using this approach, you will still define a property, but it will have protected or private visibility; the only access is through these methods. Defining a class this way ensures the validity of values without having the drawbacks of property overloading, and in particular, allows defining "property access" as part of an interface. However, it poses its own problems:

  • Additional methods for each property you want to expose that are not related to actual class behavior. This can often balloon the size of a class and its public API.
  • Accessing values via method call is slower than property access.

Generally, for data objects or value objects, you will want an immutable structure, so only getter methods will be required. As such, PHP 8.1 added the concept of readonly properties (and in 8.2, extended this to classes, which essentially just marks all declared properties as readonly). A readonly property can be written exactly once, but after that, can only be read. This approach typically means that the property is initialized in the constructor, leaving the class instance in an unchangeable state following that. It also means that you can declare a property as public with a given type declaration, and guarantee it is in a valid state:

class User
{
    public readonly string $email;
    public function __construct(string $email)
    {
        // validate $email, and then:
        $this->email = $email;
    }
}


This works for a lot of developers, but doesn't answer:

  • What if I want to allow mutation of a property value?
  • What if I want to trigger additional behavior when a property is set?
  • What if I want a property to be an aggregate of other properties?
  • Why can't we define public properties as part of an interface?

And there's a slew of other behaviors you may want as well.

And now we can get to the topic at hand: Property Hooks.

Back to top

What Are Property Hooks?

Property hooks allow you to define behavior when accessing class properties.

For the initial iteration in PHP 8.4, PHP defines "get" and "set" hooks for read and write behavior, respectively; isset operations will hit the "get" hook. The syntax for hooks looks a lot like the syntax for match expressions, with an alternative block syntax when more than one expression is required.

At its most basic, the syntax looks like this:

class User
{
    public string $email {
       get { /* expression */ }
       set ($value) { /* expression */ }
    }
}


You can define one or more hooks; you do not need to define all of them. If you do not define a hook, then standard behavior for that operation is observed. For example, if you define just a "get" hook, then any write operations will write directly to the property, following standard visibility and type rules. If you define just a "set" hook, then read operations will return the stored value directly.

"get" Hooks

Let's consider our User class. What if we want read operations to prepend a "mailto:" string, but store it internally as just the email? We could define just a "get" hook as follows:

class User
{
    public string $email {
       get {
           'mailto:' . $this->email;
        }
    }
}


Let's unpack this a little.

With a "get" hook, the return value is the value of the last expression in the block. In this case, we perform a concatenation operation, so the value of that concatenation will be returned. You'll also note that we reference $this->email. The property is backed by a value, and within the hook, we can access that directly; in that case, we get the raw value stored.

For the "get" hook, there's an alternative "short" syntax, which looks like a match expression:

class User
{
    public string $email {
       get => 'mailto:' . $this->email;
    }
}


Unlike using a block expression, the short expression requires a ; termination. The short syntax for "get" hooks is nice when you only have a single expression.

"set" Hooks

Next, let's add validation when we write to the $email property:

class User
{
    public string $email {
       get => 'mailto:' . $this->email;
       set (string $value) {
           if (! filter_var($value, FILTER_VALIDATE_EMAIL, FILTER_FLAG_EMAIL_UNICODE)) {
               throw new InvalidArgumentException('Invalid email');
           }
           $this->email = $value;
        }
    }
}


A "set" hook receives an argument, the value. You can typehint this however you want; in the example above, we only accept string values, which means that trying to set it with an integer, another object, etc., will raise an immediate TypeError. Within the "set" hook, you can do anything; here, we validate that an email was provided, and raise an exception otherwise. Finally, like with the "get" hook, we do something with the backing value, setting the raw value stored internally.

Like with the "get" hook, there's a "short" version of the "set" hook; in fact, there are a few "short" versions.

First, you can omit the "set" argument if the argument type matches the property type; when you do, the value is always provided to the expression as the variable $value.

Second, if you are only writing a single expression, you can omit the $this->[propertyName] = construct, and instead perform an expression; the return value of that expression will be used to set the backing value.

As such, you have 4 permutations:

// full block form
set ($value) { $this->[propertyName = $value }
// block form with implicit $value
set { $this->[propertyName = $value }
// expression form with explicit $value
set ($value) => {expression}
// expression form with implicit $value
set => {expression}// expression form with implicit $value


My personal take: I like the full block form, as it is most explicit, and less likely to cause confusion by a reader.

Back to top

A Developer's Guide to PHP 8.4 Property Hooks

PHP 8.4 property hooks introduce a number of new features, side effects in the language, and uses. The following guide walks through how property hooks change, enable, or impact various aspects of PHP, including:

  • Virtual Properties
  • References
  • Default Values
  • Arrays
  • Inheritance and Interfaces

Virtual Properties

One side effect of this new feature is that we can now provide virtual properties.

A virtual property is one that is not explicitly backed. The RFC for property hooks defines this as a condition whereby no defined hook references the property. So, as an example:

class User
{
    public string $fullName {
       get => $this->first . ' ' . $this->last;
    }
    public function __construct(
        private string $first,
        private string $last,
    ) {
    }
}


In this case, the $fullName property defines a "get" hook, but it does not reference $this->fullName at any point. This makes it a virtual property, and it is implicitly backed instead by the $first and $last properties.

We could also have done this from the other direction, via a "set" hook:

class User
{
    public string $fullName {
       set {
           [$this->first, $this->last] = explode(' ', $value, 2);
        }
    }
    public function __construct(
        private string $first,
        private string $last,
    ) {
    }
}


In this case, we would allow $instance->fullName = "John Doe", and doing so would populate the $first and $last properties. Additionally, since it is not a backed property, any attempt to read it will result in an error.

Of course, these hooks can be combined:

class User
{
    public string $fullName {
       get => $this->first . ' ' . $this->last;
       set {
           [$this->first, $this->last] = explode(' ', $value, 2);
        }
    }
    public function __construct(
        private string $first,
        private string $last,
    ) {
    }
}


This example gives both read and write access to the $fullName property, but its operations will read and write from other properties instead.

References

What if you want to store a reference to a property value? For instance:

$email =& $instance->email;


Normally, this means that if you make changes to $email, they will be reflected in $instance->email, and vice versa.

With property hooks, you need to explicitly specify that references are allowed, and you do that with the "get" hook:

&get => { }


However, there are some rules that apply.

For backed properties:

  • &get without a "set" hook: legal
  • &getwith a "set" hook: compile error

For virtual properties:

  • &get without a "set" hook: legal
  • &getwith a "set" hook: legal

Considering all the pitfalls of references in general in the PHP language, it's probably better to just avoid this.

Default Values

Properties can define default values:

public string $role = 'anonymous';


Properties defining hooks can as well:

public string $role = 'anonymous' {
    set {
        Roles::from($value);
        $this->role = $value;
    }
}


However, there are two things to keep in mind:

  • You CAN NOT define a default value for a virtual property, and trying to do so will result in a compile error.
  • The default value is assigned directly, and NOT passed through the "set" hook. As such, it is up to you to ensure that it is valid.

Arrays

You likely don't want to use property hooks for array-backed properties.

When making an in-place modification to an array (e.g., $instance->arrayValue[] = 1, $instance->arrayValue[$key] = $value, array_push($instance->arrayValue, 'foo'), etc.), the "set" hook will not get invoked. This is analogous to using getter and setter methods with an array as well; there's no seamless way to accomplish it.

As such, the official recommendation is to provide a narrow API contract in your class that is backed by an array, but does not directly expose it.

Future scope for property hooks includes some hooks around array access, so this may change in future PHP versions.

For more background, read the Arrays section of the RFC.

Inheritance and Interfaces

Property hooks have some well-defined and useful interactions with class inheritance and interfaces.

Consider the following class:

class StandardUser
{
    public string $email;
}


A child class could add a "set" hook to provide validations:

class ValidatedUser extends StandardUser
{
    public string $email {
       set {
           if (! filter_var($value, FILTER_VALIDATE_EMAIL, FILTER_FLAG_EMAIL_UNICODE)) {
               throw new InvalidArgumentException('Invalid email');
           }
           $this->email = $value;
        }
    }
}


You an access the property hooks for the parent class within your own hooks by adding ::get or ::set after the parent property access:

class ValidatedUser extends StandardUser
{
    // Assume $email is not marked final
    public string $email {
        final set {
           if (! filter_var($value, FILTER_VALIDATE_EMAIL, FILTER_FLAG_EMAIL_UNICODE)) {
               throw new InvalidArgumentException('Invalid email');
           }
           parent::$email::set($value);
        }
    }
}


A class might choose to make its hook final, preventing a child class from overriding it:

class StandardUser
{
    public string $email {
        final set {
           if (! filter_var($value, FILTER_VALIDATE_EMAIL, FILTER_FLAG_EMAIL_UNICODE)) {
               throw new InvalidArgumentException('Invalid email');
           }
           $this->email = $value;
        }
    }
}


And now we get to the juicy stuff: abstract classes. Prior to 8.4, an abstract class could define abstract methods. Starting in 8.4, an abstract class can ALSO define abstract properties, which MUST also define which hooks MUST be implemented:

abstract class StandardUser
{
    abstract public string $email { get; }
}


The above indicates that any extension of the class must define an $email property that is publicly readable. It does NOT have to define a "set" hook, but there's no restriction around that. Also, visibility and type must be honored, just as they would in a standard property definition.

And this brings us to perhaps the most exciting side effect of this new feature: we can now define properties on interfaces! Like with abstract properties, interface property definitions require defining the required hooks for the property.

As an example from the RFC:

interface I
{
    // An implementing class MUST have a publicly-readable property,
    // but whether or not it's publicly settable is unrestricted.
    public string $readable { get; }
 
    // An implementing class MUST have a publicly-writeable property,
    // but whether or not it's publicly readable is unrestricted.
    public string $writeable { set; }
 
    // An implementing class MUST have a property that is both publicly
    // readable and publicly writeable.
    public string $both { get; set; }
}


This means that interfaces can define (a) what properties are available on an implementation, and (b) what operations are required as part of the contract.

This feature, combined with asymmetric visibility (which we will cover in a separate blog post), will likely dramatically change approaches to data and message objects in PHP.

Back to top

Final Thoughts

Property hooks allow PHP developers to refactor their classes and remove methods where the sole purpose was to expose and modify state.

It's a huge leap forward, as it allows us to write the logic around property validation and access directly with the property. Considering that PHP is largely a glue language, many objects are really just data structures without associated behavior; property hooks enable that and mirror that goal, by allowing us to strip out getters and setters from the exposed API, and to focus on object state and behavior separately.

Expert Support and Services for PHP Applications

Zend PHP Long Term Support and Professional Services make managing your mission-critical PHP web applications easy. Take advantage of scheduled upgrades, 24/7/365 support, and more.

Discover Zend PHP LTS  See Professional Services

Additional Resources

Back to top