Understanding PHP 8.4 Property Hooks and Asymmetric Visibility
PHP 8.4 introduces two features that change how developers work with class properties: property hooks and asymmetric visibility. These additions simplify common patterns in object-oriented code, reduce the amount of boilerplate you need to write, and give you clearer control over how properties are accessed and modified.
If you have been writing getter and setter methods for properties that contain simple derived logic, property hooks let you express that behaviour more directly. If you have needed to control read and write access separately, asymmetric visibility handles that without workarounds. This article walks through both features with practical examples, explains how they work together, and covers the considerations you should keep in mind when using them in your own projects.
What Property Hooks Do
A property hook allows you to attach getter or setter logic directly to a property declaration. Before PHP 8.4, if you wanted a property that computed its value on access or validated before storing, you would write separate methods. Now you can include that logic inline with the property itself.
The hook becomes part of the property declaration. When code reads the property, the getter hook runs. When code assigns to the property, the setter hook runs. The property behaves like a regular property from the outside, but the hooks give you control over what happens during those operations.
Getter Hooks in Detail
A getter hook runs every time the property is read. The syntax uses the => operator to define an expression that produces the returned value. Here is a straightforward example with an Order class that calculates a total from subtotal and tax rate:
class Order
{
public function __construct(
private float $subtotal,
private float $taxRate = 0.20
) {}
public float $total => $this->subtotal * (1 + $this->taxRate);
}
When you access $order->total, the expression on the right side of => evaluates and returns the calculated value. You do not need a separate getTotal() method. The value updates automatically whenever subtotal or taxRate changes, because the hook runs on every access rather than caching a stale value.
Getter hooks are useful for computed properties where the value derives from other state. They are particularly effective when the calculation is straightforward and you want calling code to treat the property like any other field access.
Lazy Loading with Getter Hooks
Getter hooks also support lazy evaluation. You can combine a hook with a backing field to defer expensive computation until the property is first accessed:
class Booking
{
private ?array $cachedAvailability = null;
public array $availability {
get => $this->cachedAvailability ??= $this->queryAvailabilityFromDb();
}
private function queryAvailabilityFromDb(): array
{
// Simulated expensive database query
return ['slot1', 'slot2', 'slot3'];
}
}
The hook checks whether the cache exists and runs the query only when needed. Subsequent accesses return the cached result without running the query again. The calling code reads $booking->availability without needing to know whether it is a direct property or a computed one.
Setter Hooks in Detail
Setter hooks run whenever a value is assigned to the property. You can use them to validate input, transform data, or enforce invariants. Here is a username property that trims whitespace and enforces a minimum length:
class Username
{
public string $value {
set {
$trimmed = trim($value);
if (strlen($trimmed) < 3) {
throw new InvalidArgumentException(
'Username must be at least 3 characters.'
);
}
$this->value = $trimmed;
}
}
}
When you assign $user->value = ' john ';, the setter hook trims the whitespace and validates the length. If the value is too short, an exception is thrown. This replaces the common pattern of wrapping every property behind setter methods while keeping validation logic close to the property it protects.
Setter hooks are most effective when the logic is simple. For complex assignment logic that involves side effects, multiple steps, or coordination with other parts of the system, a dedicated method is often clearer and easier to maintain.
Combining Getter and Setter Hooks
A property can define both hooks, giving you full control over access and modification:
class Temperature
{
public float $celsius {
get => $this->kelvin - 273.15;
set {
if ($value < -273.15) {
throw new RangeException(
'Temperature cannot be below absolute zero.'
);
}
$this->kelvin = $value + 273.15;
}
}
private float $kelvin = 273.15;
}
The property celsius stores internally in Kelvin but presents a Celsius value to callers. The setter validates that the temperature is physically possible before converting and storing it. This pattern is useful for representing values in one unit while storing them in another.
Asymmetric Visibility Explained
Asymmetric visibility lets you declare different access levels for reading and writing a property. A property might be publicly readable but only privately settable, or readable within a subclass but writable only within the class itself. This works with regular properties and with property hooks.
Here is a configuration class where the API key can be read by external code but only set internally:
class Config
{
public string $apiKey {
get;
private set;
}
public function __construct(string $apiKey)
{
$this->apiKey = $apiKey;
}
}
Code outside the class can read $config->apiKey but cannot assign to it. The private set declaration restricts modification to the class itself, including methods and hooks defined within the class. This is asymmetric visibility in action: the getter and setter have different access levels.
Read-Only Properties with Internal Modification
Asymmetric visibility also applies to regular properties without hooks. You can declare a property as read-only outside the class but writable within it. This is useful for properties that should not change after construction, except in specific controlled scenarios:
class Payment
{
public function __construct(
private readonly string $transactionId,
) {}
public function __clone()
{
$this->transactionId = 'TXN-' . bin2hex(random_bytes(8));
}
}
The readonly modifier prevents external modification after construction. However, the internal __clone method can reassign the property because the assignment happens inside the class. This is particularly useful when you need to generate a new identifier for a cloned object.
Protected Write Access
You can also restrict writing to protected or private contexts while keeping the property public for reading. This is useful for entity objects where calling code should not replace the entire object but internal methods can modify state:
class Booking
{
public int $id { private set; }
public string $status { private set; }
public function __construct(int $id, string $status)
{
$this->id = $id;
$this->status = $status;
}
public function cancel(): void
{
$this->status = 'cancelled';
}
}
// External code:
// $booking->id = 99; // Error: cannot write to id from outside
$booking->cancel(); // Works: cancel() is a class method and can write private set
External code can read $booking->id and $booking->status, but only class methods can modify them. The cancel() method updates the status internally, while direct assignment from outside the class throws an error.
Using Property Hooks with Interfaces
Property hooks are compatible with interfaces. You can declare a hook in an interface and implement it in a class, which is useful for defining contracts around computed values without dictating the implementation details:
interface Billable
{
public float $total { get; }
}
class Invoice implements Billable
{
public function __construct(
private float $subtotal,
private float $tax
) {}
public float $total => $this->subtotal + $this->tax;
}
The interface requires a getter for total. The implementing class provides the hook that computes the value. Any class that implements Billable must expose a readable total property, but the interface does not prescribe how the value is calculated.
You can also declare both hooks in an interface when you need to enforce both getter and setter behaviour:
interface Validated
{
public string $input {
get;
set;
}
}
class FormField implements Validated
{
public string $input {
get => $this->sanitize($this->rawInput);
set => $this->rawInput = $this->sanitize($value);
}
private string $rawInput = '';
private function sanitize(string $value): string
{
return htmlspecialchars(trim($value), ENT_QUOTES, 'UTF-8');
}
}
The interface defines the contract. The implementing class provides the hook logic. This approach lets you enforce a consistent interface across implementations while allowing each class to handle the logic appropriately.
Backward Compatibility Considerations
When adding hooks to existing properties, you need to consider whether external code directly accesses those properties. If external code was reading or writing the property directly, adding a hook changes the behaviour of that access, which can break existing code.
Before adding hooks to properties that are part of a public or protected interface, audit any code that reads or writes those properties. Check subclasses, traits, and any external code that instantiates or extends your classes. If the property was public and you add a hook, any code that was directly accessing the storage will now trigger the hook logic instead.
For new code, property hooks are safe and reduce the number of methods you need to write and maintain. When refactoring existing code to use hooks, introduce them gradually and test thoroughly. If you are maintaining a library with external users, treat the addition of hooks to existing public properties as a breaking change and document it accordingly.
Note: Property hooks are currently supported only on instance properties. Static properties do not support hooks in PHP 8.4.
When to Choose Property Hooks Over Methods
Property hooks work best when the behaviour is simple, self-contained, and does not warrant a separate method. Computed values that derive from other properties, input validation that can be expressed in a few lines, and formatters that apply consistently are good candidates.
Use a dedicated method instead when assignment or retrieval involves complex logic, side effects, multiple steps, or coordination with other parts of the system. A method makes the operation explicit, documents its purpose in the method name, and is easier to test independently.
If you find yourself writing a hook that spans many lines, performs multiple operations, or calls other methods, consider whether a regular method would be clearer for the next developer who reads your code.
Putting It Together
PHP 8.4 property hooks and asymmetric visibility address common patterns that developers have been implementing manually for years. Getter hooks replace boilerplate methods for computed properties. Setter hooks centralise validation logic. Asymmetric visibility controls access without requiring separate read and write methods.
These features work well together. You can define a property with a public getter and a private setter, attach hooks to control the logic, and implement interfaces that declare the expected behaviour. The result is cleaner code that expresses intent more directly.
If you are starting a new PHP project or refactoring existing code, consider where property hooks can simplify your classes. For computed values, formatters, and validation that lives close to the data it handles, hooks offer a more readable alternative to separate methods. Asymmetric visibility is useful whenever you need different access levels for reading and writing, particularly in entity classes, configuration objects, and value objects.
As with any language feature, use property hooks where they genuinely improve clarity. They are a tool, not a requirement, and they work best when the behaviour they express is simple and self-contained.