PHP 8.4 property hooks change how we write classes: validation and computed values can live on the property itself instead of in a wall of getters and setters. This guide explains property hooks and asymmetric visibility with working examples, then covers the new array functions, new without extra parentheses, the implicit nullable deprecation and practical tips for migrating.
Why PHP 8.4 property hooks exist
For years the “safe” way to write a PHP class was private properties plus getX() and setX() methods, just in case you might one day need logic there. Most of those methods did nothing. Constructor promotion and readonly (covered in my modern PHP 8 features guide) cut down the boilerplate, but they didn’t help when you needed validation on write or a derived value on read.
Property hooks, from the PHP 8.4 “Property hooks” RFC, let you attach get and set logic directly to a property. Callers keep using plain property syntax, $user->email, and you can add logic later without changing your public API.
The set hook: validate and normalise on write
<?php
final class User
{
public string $email {
set(string $value) {
$value = strtolower(trim($value));
if (! filter_var($value, FILTER_VALIDATE_EMAIL)) {
throw new InvalidArgumentException("Invalid email: {$value}");
}
$this->email = $value;
}
}
public function __construct(string $email)
{
$this->email = $email; // runs the set hook
}
}
$user = new User(' [email protected] ');
echo $user->email; // [email protected]
$user->email = 'not-an-email'; // throws InvalidArgumentException
Inside the hook, $this->email refers to the property’s own backing value, so assigning to it stores the value rather than calling the hook again. For simple transformations there’s a short form: set => expression assigns the result of the expression to the property.
<?php
final class Tag
{
public string $slug {
set => strtolower(preg_replace('/[^a-z0-9]+/i', '-', trim($value)));
}
}
When you omit the parameter list, the incoming value is available as $value. You can also declare a wider parameter type than the property, for example set(string|Stringable $value), as long as what you store matches the property’s type.
The get hook and virtual properties
A get hook runs whenever the property is read. If neither hook refers to $this->propertyName, the property has no stored value at all; it’s a virtual property, computed on demand.
<?php
final class Customer
{
public function __construct(
public string $firstName,
public string $lastName,
) {}
public string $fullName {
get => "{$this->firstName} {$this->lastName}";
}
}
$c = new Customer('Asha', 'Rao');
echo $c->fullName; // Asha Rao
$c->fullName = 'x'; // Error: property has no set hook, so it can't be written
Virtual properties take no memory per object and don’t appear in serialised data, which suits derived values like a full name, a formatted price or an isExpired flag.
A few rules from the RFC worth knowing before you refactor:
- Hooked properties can’t be declared
readonly. Use asymmetric visibility (below) for read-only-from-outside behaviour instead. - Hooks work with constructor promotion, but keep them short there or the constructor becomes unreadable.
- Interfaces and abstract classes can now declare properties with required hooks, such as
public string $name { get; }, so a contract can demand a readable property instead of a getter method. - Indirectly modifying an array property that has a
sethook, like$obj->items[] = $x, is an error, because the hook can’t intercept a write to one element. Provide a method such asaddItem()instead.
<?php
interface HasLabel
{
public string $label { get; }
}
final class Plan implements HasLabel
{
public function __construct(private string $name, private int $priceCents) {}
public string $label {
get => sprintf('%s (₹%s/month)', $this->name, number_format($this->priceCents / 100));
}
}
Asymmetric visibility: public read, private write
The second big feature, from the “Asymmetric Visibility v2” RFC, lets a property have a different visibility for writing than for reading. The syntax puts the write scope in parentheses with (set):
<?php
final class Order
{
public private(set) string $status = 'pending';
public protected(set) array $lines = [];
public function __construct(public private(set) string $id) {}
public function markPaid(): void
{
$this->status = 'paid'; // allowed inside the class
}
}
$order = new Order('A-100');
echo $order->status; // pending
$order->status = 'paid'; // Error: cannot modify private(set) property from global scope
$order->markPaid(); // fine
This replaces the common pattern of a private property with a public getter. Unlike readonly, the class itself can change the value as often as it needs to, which makes it a better fit for entities with state transitions.
Key rules:
- The property must have a type declaration.
- The set visibility must be equal to or narrower than the get visibility.
- If the read visibility is
public, you can omit it:private(set) string $idmeans public read, private write. - A
private(set)property is implicitly final, since child classes can’t write to it anyway.
Asymmetric visibility and hooks combine well: visibility controls who may write, and a set hook controls what is accepted.
<?php
final class Product
{
public private(set) int $stock = 0 {
set {
if ($value < 0) {
throw new DomainException('Stock cannot be negative.');
}
$this->stock = $value;
}
}
public function remove(int $qty): void
{
$this->stock -= $qty; // goes through the set hook
}
}
New array functions: array_find, array_find_key, array_any, array_all
PHP 8.4 adds four small functions that remove a lot of hand-written foreach loops. Each takes the array and a callback that receives the value and the key.
<?php
$users = [
'u1' => ['name' => 'Asha', 'role' => 'admin', 'active' => true],
'u2' => ['name' => 'Ravi', 'role' => 'editor', 'active' => false],
'u3' => ['name' => 'Meera', 'role' => 'editor', 'active' => true],
];
$firstEditor = array_find($users, fn (array $u) => $u['role'] === 'editor');
// ['name' => 'Ravi', ...] (null if nothing matches)
$firstEditorKey = array_find_key($users, fn (array $u) => $u['role'] === 'editor');
// 'u2'
$hasInactive = array_any($users, fn (array $u) => ! $u['active']); // true
$allNamed = array_all($users, fn (array $u) => $u['name'] !== ''); // true
Unlike array_filter(), these stop as soon as the answer is known. One gotcha: array_find() returns null when nothing matches, so if your array can contain null values, use array_find_key() and check the key instead.
new without parentheses for method chaining
You can now access members on a newly created object without wrapping the new expression in parentheses:
<?php
// PHP 8.3 and earlier
$response = (new ResponseBuilder())->withStatus(201)->build();
// PHP 8.4
$response = new ResponseBuilder()->withStatus(201)->build();
$name = new ReflectionClass($obj)->getShortName();
The constructor parentheses are required for this form: new ResponseBuilder->build() is still a parse error, because PHP can’t tell where the class name ends.
Deprecation: implicitly nullable parameter types
This is the change most likely to show up in your logs after upgrading. Before 8.4, giving a typed parameter a default of null made it nullable implicitly. PHP 8.4 deprecates that:
<?php
// Deprecated in PHP 8.4
function findUser(int $id, Options $options = null) {}
// Fix: make the type explicitly nullable
function findUser(int $id, ?Options $options = null) {}
// Or with a union type
function findUser(int $id, Options|null $options = null) {}
The fix is mechanical, and it works on PHP 7.1 and later, so libraries can apply it without dropping support for older versions. Tools like Rector and PHP-CS-Fixer have rules that rewrite these automatically.
Other 8.4 changes worth a look in the release notes include the #[Deprecated] attribute for your own functions, lazy objects, new mb_trim() family functions, an HTML5-compliant DOM API under the Dom namespace, and the E_STRICT constant being deprecated.
Migration tips for PHP 8.4
- Upgrade the runtime first, the code second. Run your test suite on 8.4 in CI alongside your current version, and fix deprecations before using new syntax. My PHP unit testing guide shows a GitHub Actions matrix for exactly this.
- Automate the nullable fix. Let Rector or PHP-CS-Fixer rewrite implicit nullable types across the codebase in one commit.
- Check dependencies. Libraries must support 8.4 before you can deploy it, and packages that use hooks or asymmetric visibility will require 8.4 as a minimum.
- Adopt hooks gradually. Good first candidates are trivial getter/setter pairs and private-property-plus-getter patterns, which become
public private(set). - Watch magic methods and serialisation. Virtual properties aren’t serialised, and hooked properties behave differently with references. Run tests on any code that serialises entities or uses
__get(). - Update tooling. Static analysers, IDEs and code formatters need versions that understand the new syntax, or they’ll report false errors.
Key takeaways
- Property hooks attach
getandsetlogic to a property, so callers keep using property syntax. - A property whose hooks never touch
$this->propis virtual and has no stored value. public private(set)gives read access everywhere and write access only inside the class.array_find(),array_find_key(),array_any()andarray_all()replace common search loops.new Foo()->bar()works without wrapping parentheses, but the constructor parentheses are required.- Replace implicit nullable types like
Type $x = nullwith?Type $x = null.
Frequently asked questions
Can a property with hooks be readonly in PHP 8.4?
No. Hooked properties cannot be declared readonly. Use asymmetric visibility such as public private(set) when you want outside code to read but not write the property.
What is the difference between readonly and private(set)?
A readonly property can be initialised only once and never changed again, even by the class itself. A private(set) property can be changed any number of times, but only by code inside the class.
Do property hooks replace getters and setters?
For most simple cases, yes, because you can add logic later without changing the public API. Methods are still the better choice when an operation needs extra parameters or has a meaningful name like markPaid().
How do I fix the implicitly nullable parameter deprecation?
Add an explicit nullable marker to the type, changing Type $param = null to ?Type $param = null. Rector and PHP-CS-Fixer can apply this change across a codebase automatically.
Planning a PHP 8.4 upgrade or modernising an older codebase? I can help you migrate safely and adopt the new features where they make sense. Get in touch.