Property hooks in php 8.4: a simpler way to manage object properties
PHP is evolving—every release marks a milestone toward making the language easier and more developer-friendly. With the release of PHP 8.4 on November 21, 2024, a powerful new feature called property hooks was introduced, which we previously discussed in our post An Overview of PHP 8.4 New Features.
Today, we'll dive deeper into this feature. We'll explore what property hooks are, how they work, walk through real-world examples, and yes—highlight the pitfalls you might stumble into.
What Are Property Hooks?
Traditionally, in PHP, if you wanted to control how a property is read or written, you had two main options:
- Make the property private and write a
getter()and/orsetter()method to read and write to the property respectively. It is not only slow but also makes the class bloated. - Use the magic methods
__get()and__set()for dynamic property access. These allow you to define logic even for properties that don’t exist on the object—though this approach is often messy and harder to maintain.
Property hooks are special code blocks, get and set, that you can attach to a class non-static properties. These blocks are automatically triggered when a value is read from or written to the property. These hooks can be used with both typed and untyped properties. This is a huge leap forward from magic methods like __get() and __set(), which are less explicit and can be harder to debug and perform static analysis.
Property hooks cannot be used with readonly properties. If you need to both control access and customize the behavior of getters or setters, you can use asymmetric property visibility.
Properties can be classified as either "backed" or "virtual." A backed property stores an actual value and includes any property without hooks. A virtual property, on the other hand, has hooks that do not interact with the property itself, functioning similarly to methods, and does not require storage space for a value. means virtual property is computed and may have backed property references.
Syntax and Basic Examples
The syntax is straightforward, instead of ending your property with a semicolon, you append a curly brace block {} and define your hooks inside. You can have a get hook, a set hook, or both. Order doesn't matter. Both hooks have a body, denoted by {}, that may contain arbitrary code and if the block's empty, PHP throws a compile error.
Example - a simple example illustratiing the usages of property hooks:
// Example : With PHP 8.4 Property Hooks:
class User {
public string $username {
set (string $value) {
if (strlen($value) < 3) {
throw new InvalidArgumentException("Username must be at least 3 characters");
}
$this->username = strtolower($value);
}
get {
return ucfirst($this->username);
}
}
}
$user = new User();
$user->username = "JohnDoe"; // Sets to "johndoe"
echo $user->username; // Output: Johndoe
The example above defines both get and set hooks that override the default behavior of the $username property for read and write operations. The $username property is considered a backed property because it references itself within its hooks. However, to be a backed property, it typically should not have hooks. If hooks are present, at least one of the get or set hooks must reference the property itself. On a backed property, omitting a get or set hook means the default read or write behavior will be used.
You might have observed in the previous example that the set hook can accept an argument, though this is entirely optional. When no argument is provided, the assigned value is automatically accessible within the set block through the $value variable (see the example below). However, if you require greater control over the value, you can explicitly pass an argument to the set hook by specifying both its type and name as shown in previous example.
Example - set hook without explicit argument:
class Product {
public float $price {
set {
if ($value < 0) {
throw new InvalidArgumentException('Price can't be negative!');
}
$this->price = $value; // This accesses the backing value directly
}
}
}
$p = new Product();
$p->price = 19.99; // Fine
$p->price = -5; // Throws exception
echo $p->price; // Outputs: 19.99 (normal read)
Virtual Property:
Virtual properties exist only within the context of property hooks. For a property to be virtual, it must have one or both hooks (get/set) present, and crucially, none of these hooks can reference the property itself - meaning there's no backing storage for the property's value.
Let's illustrate this concept with an example:
class Rectangle {
public function __construct(
private float $width,
private float $height,
) { }
public float $area {
get {
return $this->width * $this->height;
}
}
}
In this example, the $area property has only a get hook that doesn't reference itself ($this->area). Instead, the get hook calculates the area using other backed properties ($width and $height). Since the $area property lacks a set hook, any attempt to assign a value like $rectangle->area = 50.0; will produce an error.
A virtual property can have both hooks. Here's a modified example that includes a set hook:
class Temperature {
public function __construct(
private float $celsius,
) { }
public float $fahrenheit {
get {
return ($this->celsius * 9/5) + 32;
}
set {
$this->celsius = ($value - 32) * 5/9;
}
}
}
In this case, we can assign $temp->fahrenheit = 68.0, which would automatically update the $celsius property to the equivalent temperature (20.0°C).
Key Points:
- A virtual property may have either one or both hooks
- If a hook is omitted, that operation doesn't exist and attempting to use it produces an error
- Virtual properties consume no memory space since they store no actual value
- They're ideal for "derived" properties that combine or transform other property values
- Whether a property is backed or virtual is determined at compile time
Arrow Expression Syntax:
When a property hook contains only a single expression, it can be simplified using arrow expression syntax, allowing you to omit the curly braces {}. Either one or both hooks can use this shortened syntax. This shortened syntax is totally optional, you can stick to block syntax using curly brace {} as per your preferences.
Example - Hooks with Arrow Expression:
<?php
// Example 1: Both get and set hooks use short syntax
class User {
private string $name;
public string $name {
get => ucfirst($this->name); // Short syntax for getter
set => $this->name = strtolower($value); // Short syntax for setter
}
}
// Example 2: Only get hook uses short syntax, set hook uses full syntax
class User {
private string $name;
public string $name {
get => ucfirst($this->name); // Short syntax for getter
set { // Full syntax for setter with validation
if (strlen($value) < 3) {
throw new InvalidArgumentException('Name must be at least 3 characters long!');
}
$this->name = strtolower($value); // Assign to backing property
}
}
}
// Usage example
$user = new User();
$user->name = "john"; // Setter converts to lowercase
echo $user->name; // Getter outputs "John"
Property Hook Scoping:
All property hooks execute within the scope of the object they belong to. This means:
- They have access to all of the object’s members—whether
public,protected, orprivate—including other properties that themselves may use hooks. - When a hook accesses another property, it does not bypass that property’s own hooks; the access will still go through its defined getter or setter.
- As a result, a hook can perform complex logic if needed, including delegating work to other private methods or even chaining into other property hooks.
The key implication is that hooks are not limited in complexity—they can be as simple as returning a computed value, or as advanced as invoking helper methods with arbitrarily complex logic.
References vs. set hooks
A set hook provides a way to execute custom logic whenever a property is assigned a new value. If you could get a reference to the underlying property and modify it directly, you could completely bypass this set logic, which would break the class's integrity and purpose.
For this reason, the following behaviors are explicitly disallowed:
- Creating a reference: If a backed property has a
sethook, you cannot get a reference to it ($ref = &$object->myProperty;). - Indirect modification: You cannot perform direct array modifications (e.g.,
$object->myArray[] = 'new';) on a backed array property that has asethook. This is because such an operation is an implicit reference modification.
How to use references with property hooks
To explicitly allow getting a reference to a property's value, you must define a &get hook. The & prefix on the get hook signals that this is an intentional "opt-in" to allow modifications via reference.
- No
sethook allowed: A backed property with a&gethook cannot also have asethook, since that would create a conflict. - Bypasses
set: When you modify a value through a reference obtained from&get, you are directly altering the backing property, and anysetlogic is bypassed.
Example - Lazy-loading an array:
A common and legal use case for &get is to implement lazy-loading for a property
class Post {
private array $tags = [];
// The '&' indicates this hook returns a reference
public array $tags {
&get {
// Lazy-load the tags only when first accessed
if (empty($this->tags)) {
$this->tags = $this->loadTagsFromDatabase();
}
return $this->tags;
}
}
private function loadTagsFromDatabase(): array {
echo "Loading tags...\n";
return ['php', 'hooks', 'reference'];
}
}
$post = new Post();
// The `&get` hook is called on the first access
$tagsRef = &$post->tags;
$tagsRef[] = 'oop'; // Modifies the private backing property directly
// The original object's property now reflects the change
print_r($post->tags);
/* Output:
Loading tags...
Array
(
[0] => php
[1] => hooks
[2] => reference
[3] => oop
)
*/
Default Values in Properties with Hooks
Properties that define hooks (get and/or set) can also be assigned default values, similar to regular properties. However, there are important restrictions to keep in mind:
- You cannot assign a default value to a virtual property. Attempting to do so will result in a compile-time error, since virtual properties do not have backing storage.
- Default values bypass the
sethook. This means the value is assigned directly to the backing field without triggering any logic inside thesethook. Therefore, it's your responsibility to ensure the default value is valid and consistent with any constraints you might enforce in thesethook.
Example - Default Value with a set Hook:
public string $gender = 'unknown' {
set {
// Example validation
if (!in_array($value, ['male', 'female', 'unknown'])) {
throw new InvalidArgumentException("Invalid gender value.");
}
$this->gender = $value;
}
}
Pitfalls of Property Hooks: Don't Get Hooked the Wrong Way
Alright, time for the reality check. Property hooks are powerful, but they're not magic unicorns. Here are some gotchas I've run into or seen discussed:
- No References or Indirect Modifications with Set Hooks: If you have a set hook, you can't take references to the property (like &$obj->prop) or do indirect mods (e.g., $obj->arrayProp['key'] = 'value';). This bypasses the hook, so PHP blocks it to prevent bugs. It's a safety net, but it means you might need separate mutator methods for complex stuff.
- Array Operations Are Tricky: Things like $obj->arrayProp[] = 'new item'; are outright disallowed if there's a set hook. The RFC folks decided it was too performance-heavy to support, so stick to methods for array mutations.
Wrapping Up
Property hooks in PHP 8.4 are a breath of fresh air for cleaner OOP, but watch those pitfalls to avoid turning your code into a maze. Asymmetric visibility pairs nicely with them, offering a step up from readonly for more nuanced control. If you're on 8.4, experiment in a sandbox—it's worth it for reducing boilerplate.