PHP 8.4 introduced one of the most significant additions to the language's object model in years: property hooks. If you've 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 dig into 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's 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's getCelsius() or celsius() or temperature() — it's 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. I've started using this pattern on almost every value object in my projects.
Virtual Properties
A property with only a get hook and no backing store is called a virtual property. It doesn't consume any memory on the object — it's computed fresh every time you access it:
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 can't 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. That alone is worth the upgrade — I've lost count of how many bugs I've traced back to objects that were half-initialized because someone called a setter out of order.
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 works.
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 — the config object just refuses to hold bad data.
Conclusion
PHP 8.4 property hooks are one of those features that changes how you think about class design once you start using them. The boilerplate getter/setter era is over — your properties can finally enforce their own rules without wrapping everything in method calls.
If you're on 8.4 already, start with your value objects — that's where you'll feel the biggest difference. Use set hooks for normalization and validation, use get-only virtual properties for computed values, and pair them with asymmetric visibility when you want a clean read-only public API. Your classes get shorter, your domain logic stays where it belongs, and you'll wonder why PHP didn't have this ten years ago.