PHP 8.4 introduced one of the most significant additions to the language's object model in years: property hooks. If you have ever written a class where half the code is boilerplate getters and setters that do almost nothing, property hooks are here to fix that. They let you define get and set behavior directly on a property declaration, eliminating an entire category of repetitive code.
Let's walk through exactly how they work, where they shine, and the patterns they unlock for cleaner domain models.
The Syntax
Property hooks attach get and set logic directly to a property using a block after the property declaration:
class Product
{
public string $name {
get => strtoupper($this->name);
set (string $value) => strtolower($value);
}
}
The get hook runs every time the property is read. The set hook runs every time a value is assigned. Both are optional — you can define one without the other. For single-expression hooks you can use the short arrow syntax shown above. For multi-line logic, use a full block:
class Product
{
public string $slug {
set (string $value) {
$value = trim($value);
$value = preg_replace('/[^a-z0-9]+/', '-', strtolower($value));
$this->slug = $value;
}
}
}
When using the arrow syntax in a set hook, the returned value is implicitly assigned to the backing property. In a block-bodied set hook, you need to assign to $this->propertyName explicitly.
Before and After: Replacing Traditional Getters and Setters
Here is a typical pre-8.4 class with manual getters and setters:
// Before: PHP < 8.4
class Temperature
{
private float $celsius;
public function getCelsius(): float
{
return $this->celsius;
}
public function setCelsius(float $celsius): void
{
if ($celsius < -273.15) {
throw new \InvalidArgumentException('Below absolute zero.');
}
$this->celsius = $celsius;
}
public function getFahrenheit(): float
{
return ($this->celsius * 9 / 5) + 32;
}
}
$temp = new Temperature();
$temp->setCelsius(100);
echo $temp->getFahrenheit(); // 212
Now the same class with property hooks:
// After: PHP 8.4
class Temperature
{
public float $celsius {
set (float $value) {
if ($value < -273.15) {
throw new \InvalidArgumentException('Below absolute zero.');
}
$this->celsius = $value;
}
}
public float $fahrenheit {
get => ($this->celsius * 9 / 5) + 32;
}
}
$temp = new Temperature();
$temp->celsius = 100;
echo $temp->fahrenheit; // 212
The class drops from 20+ lines to about 15, and more importantly, the API surface reads like plain property access. No more memorizing whether it is getCelsius() or celsius() or temperature() — it is just $temp->celsius.
Asymmetric Visibility with Hooks
PHP 8.4 also introduced asymmetric visibility, and it pairs beautifully with hooks. You can make a property publicly readable but only privately (or protectedly) settable:
class User
{
public private(set) string $email {
set (string $value) => strtolower(trim($value));
}
}
$user = new User();
// $user->email = 'TEST@EXAMPLE.COM'; // Error: cannot set from outside the class
echo $user->email; // Readable from anywhere
This gives you immutable-from-the-outside properties that still normalize their values on internal assignment. No more writing a public getter and a private setter — the visibility modifier handles it.
Virtual Properties
A property with only a get hook and no backing store is called a virtual property. It does not consume any memory on the object and is computed on the fly every time it is accessed:
class Circle
{
public function __construct(
public float $radius,
) {}
public float $area {
get => M_PI * $this->radius ** 2;
}
public float $circumference {
get => 2 * M_PI * $this->radius;
}
}
$circle = new Circle(5.0);
echo $circle->area; // 78.5398...
echo $circle->circumference; // 31.4159...
Virtual properties cannot be assigned to since they have no set hook and no storage. They replace the common pattern of writing a dozen getCalculatedThing() methods and let consumers treat derived values just like regular properties.
Validation in Set Hooks
One of the most practical uses of set hooks is inline validation. Instead of scattering validation across form requests, constructors, and setter methods, you can enforce invariants right where the data lives:
class Money
{
public int $amount {
set (int $value) {
if ($value < 0) {
throw new \DomainException('Amount cannot be negative.');
}
$this->amount = $value;
}
}
public string $currency {
set (string $value) {
if (strlen($value) !== 3) {
throw new \DomainException('Currency must be a 3-letter ISO code.');
}
$this->currency = strtoupper($value);
}
}
public function __construct(int $amount, string $currency)
{
$this->amount = $amount;
$this->currency = $currency;
}
}
$price = new Money(1999, 'usd'); // Works, currency stored as 'USD'
$price = new Money(-5, 'USD'); // Throws DomainException
The hooks fire even during construction, so your object can never exist in an invalid state. This is a huge win for domain modeling.
Hooks and Interfaces
Interfaces can declare properties with hook contracts. This means you can require implementing classes to provide certain property behavior:
interface HasFullName
{
public string $fullName { get; }
}
class Employee implements HasFullName
{
public function __construct(
public string $firstName,
public string $lastName,
) {}
public string $fullName {
get => "{$this->firstName} {$this->lastName}";
}
}
The interface declares that $fullName must be readable (it requires a get hook). The implementing class provides the virtual property. This is a cleaner contract than requiring a getFullName() method — consumers can just read $employee->fullName and the interface guarantees it exists.
You can also require set in an interface, or both get and set, giving you fine-grained control over what implementing classes must support.
Real-World Patterns
A Money Value Object
class Money
{
public private(set) int $cents {
set (int $value) {
if ($value < 0) {
throw new \DomainException('Money cannot be negative.');
}
$this->cents = $value;
}
}
public private(set) string $currency {
set (string $value) => strtoupper($value);
}
public string $formatted {
get => number_format($this->cents / 100, 2) . ' ' . $this->currency;
}
public function __construct(int $cents, string $currency)
{
$this->cents = $cents;
$this->currency = $currency;
}
public function add(Money $other): self
{
if ($this->currency !== $other->currency) {
throw new \DomainException('Cannot add different currencies.');
}
return new self($this->cents + $other->cents, $this->currency);
}
}
$price = new Money(2999, 'usd');
echo $price->formatted; // "29.99 USD"
Asymmetric visibility keeps the object immutable from the outside. The set hooks enforce invariants. The virtual $formatted property provides a computed view.
A User Model
class User
{
public string $email {
set (string $value) => strtolower(trim($value));
}
public private(set) string $passwordHash;
public string $password {
set (string $value) {
if (strlen($value) < 8) {
throw new \InvalidArgumentException('Password must be at least 8 characters.');
}
$this->passwordHash = password_hash($value, PASSWORD_BCRYPT);
}
}
public string $displayName {
get => $this->nickname ?? $this->email;
}
public ?string $nickname = null;
}
Here $password is a write-only property with side effects (it hashes and stores into a different property). The $displayName virtual property provides a fallback chain. The $email set hook normalizes input automatically.
A Settings DTO
class AppSettings
{
public string $timezone {
set (string $value) {
if (! in_array($value, timezone_identifiers_list())) {
throw new \InvalidArgumentException("Invalid timezone: {$value}");
}
$this->timezone = $value;
}
}
public int $maxUploadSizeMb {
set (int $value) {
if ($value < 1 || $value > 100) {
throw new \RangeException('Upload size must be between 1 and 100 MB.');
}
$this->maxUploadSizeMb = $value;
}
}
public int $maxUploadSizeBytes {
get => $this->maxUploadSizeMb * 1024 * 1024;
}
}
The DTO validates its own data on assignment and exposes computed values as virtual properties. No separate validation layer needed for configuration objects.
Conclusion
Property hooks fundamentally change how you design PHP classes. They collapse the getter/setter boilerplate that has plagued PHP for decades into a concise, expressive syntax that lives right on the property declaration. Combined with asymmetric visibility and virtual properties, they give you the tools to build self-validating value objects, clean DTOs, and domain models that enforce their own invariants.
The key patterns to remember: use set hooks for normalization and validation, use get-only virtual properties for computed values, and use asymmetric visibility to control your public API surface. Your classes get shorter, your APIs get cleaner, and your domain logic stays exactly where it belongs — on the data itself.