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.
These hooks are particularly relevant if you are working on modern PHP projects where clean, readable code matters. Similar to how earlier PHP versions introduced features that reduced boilerplate, property hooks continue that trend by removing the need for repetitive accessor methods.
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.
This lazy loading pattern was previously common in PHP applications but required manual implementation with separate methods. Getter hooks make the pattern cleaner and keep the lazy-loading logic close to the property it affects.
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. If you find yourself writing a hook that spans many lines, consider whether a regular method would serve better.
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.
When both hooks are present, they work together seamlessly. The getter transforms the stored value on output, and the setter transforms the input before storing. This is particularly useful for value objects that need to present one representation while storing another internally.
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.
Understanding when to use classes and when to use simpler constructs remains relevant here. Property hooks and asymmetric visibility are tools within the object-oriented approach, and they work best when applied to problems that genuinely benefit from encapsulation.
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.
This pattern enforces a clearer contract with calling code. Instead of relying on conventions or documentation to explain that certain properties should not be modified directly, the language enforces the constraint directly.
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.
Interfaces with property hooks are particularly useful in larger codebases where multiple implementations may compute values differently but should present the same contract to calling code.
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.
Property Hooks and Object-Oriented Design
Property hooks fit naturally within object-oriented design principles. They support encapsulation by keeping related logic close to the data it affects, and they reduce the surface area of your public API by eliminating unnecessary accessor methods.
When designing classes, consider using hooks alongside other OOP patterns. The choice between inheritance and composition remains relevant when deciding how to share hook implementations across classes. Hooks in traits work well when the behaviour is composable, while inheritance may suit scenarios where a clear parent-child relationship exists.
Asymmetric visibility also interacts with OOP concepts. Protected write access, for example, lets subclasses read parent properties but restricts modification to the declaring class. This mirrors the intent of protected access in traditional OOP: sharing state with subclasses while controlling how it changes.
Performance and Practical Considerations
Property hooks introduce minimal overhead compared to direct property access. The hook logic executes in the same way a getter or setter method would, but the syntax is cleaner and the intent is more explicit.
One practical consideration is debugging. When a hook throws an exception, the stack trace will show the property assignment rather than a method call. This can make debugging slightly different from traditional getter and setter methods, though modern IDEs and debuggers handle hook-based code effectively.
When using hooks with expensive operations, be mindful of when the hook runs. Getter hooks that perform I/O or complex computation will run every time the property is accessed unless you implement caching, as shown in the lazy loading example earlier. Design your hooks with this behaviour in mind.
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.
If you are working on PHP projects and want to discuss how these features might apply to your codebase, you can get in touch to explore practical approaches for your specific setup.