PHP 8.4 property hooks changed how I write classes (and I’m not going back)
I mass-updated a Laravel project to PHP 8.4 last week, mostly to get the security patches. Then I saw property hooks in the changelog and spent the next three hours rewriting code that already worked. No regrets.
If you’ve written PHP for any length of time, you know the getter/setter dance. Private property, public getter, maybe a setter with validation, repeat forty times across a codebase. It works fine. It’s also the kind of boilerplate that makes you mass-select lines and wonder if you picked the wrong language. PHP 8.4 property hooks kill most of that boilerplate, and they do it without the magic method chaos that __get and __set always brought.
Let me walk through what’s different and show you actual code.
The old getter/setter ceremony
Before 8.4, enforcing read-only access or adding validation to a property meant writing methods. Everybody knows this pattern:
class Product
{
private string $name;
private int $priceInCents;
public function __construct(string $name, int $priceInCents)
{
$this->name = $name;
$this->setPriceInCents($priceInCents);
}
public function getName(): string
{
return $this->name;
}
public function getPriceInCents(): int
{
return $this->priceInCents;
}
public function setPriceInCents(int $price): void
{
if ($price < 0) {
throw new \InvalidArgumentException('Price cannot be negative');
}
$this->priceInCents = $price;
}
public function getFormattedPrice(): string
{
return '$' . number_format($this->priceInCents / 100, 2);
}
}
That’s 30-something lines for two properties and one computed value. The actual logic is maybe five lines. The rest is ceremony.
Property hooks: what they actually look like

PHP 8.4 lets you attach get and set hooks directly to properties. Same class, less noise:
class Product
{
public string $name;
public int $priceInCents {
set(int $value) {
if ($value < 0) {
throw new \InvalidArgumentException('Price cannot be negative');
}
$this->priceInCents = $value;
}
}
public string $formattedPrice {
get => '$' . number_format($this->priceInCents / 100, 2);
}
public function __construct(string $name, int $priceInCents)
{
$this->name = $name;
$this->priceInCents = $priceInCents;
}
}
The validation lives right where the property is declared. The computed $formattedPrice is a virtual property with only a get hook, so it reads like a property but calculates on access. No separate method, no getFormattedPrice() call in your templates.
You access everything the same way you always have: $product->formattedPrice, $product->priceInCents = 500. The hooks fire transparently.
Asymmetric visibility is the other half of this
PHP 8.4 also shipped asymmetric visibility, and it pairs well with property hooks. You can make a property publicly readable but only privately (or protectedly) settable:
class User
{
public private(set) string $email;
public private(set) string $role = 'viewer';
public function __construct(string $email)
{
$this->email = $email;
}
public function promote(): void
{
$this->role = 'admin';
}
}
Before this, you’d write a public getter and keep the property private. Or you’d use readonly, which is too strict when the class itself needs to update the value later. Asymmetric visibility fills that gap without extra methods.
I went through a Laravel app and removed about 40 getters that existed purely to expose a privately-set property. The diff was satisfying.
Where hooks actually shine in Laravel
DTOs and value objects are where I’ve gotten the most mileage. If you build form request objects or API response shapes, you probably have classes that are 80% boilerplate:
// Before: the old way with manual casting
class MoneyDto
{
private int $cents;
public function __construct(int $cents)
{
$this->cents = max(0, $cents);
}
public function getCents(): int
{
return $this->cents;
}
public function getDollars(): float
{
return $this->cents / 100;
}
}
// After: PHP 8.4 with hooks
class MoneyDto
{
public int $cents {
set(int $value) {
$this->cents = max(0, $value);
}
}
public float $dollars {
get => $this->cents / 100;
}
public function __construct(int $cents)
{
$this->cents = $cents;
}
}
The second version is shorter, but the real win is that $dollars behaves like a property everywhere. In Blade templates, JSON serialization, wherever. You don’t need to remember whether it was getDollars() or ->dollars or something else.
Eloquent model accessors already had something like this with the Attribute cast system Laravel introduced a while back. Property hooks don’t replace that for database models (Eloquent has its own thing going on), but for everything else, plain PHP properties with hooks are simpler and need zero framework dependency.
Things that tripped me up
Property hooks aren’t magic, and I ran into a few rough edges during the migration.
First, hooks and constructor promotion don’t always play nice together. You can use promoted properties with hooks in 8.4, but the syntax gets crowded fast. I found it cleaner to keep the property declaration separate when it has a hook and only use promotion for simple assignments.
Second, virtual properties (get-only, no stored value) can’t be set at all, which sounds obvious but caught me when I tried to mass-assign from an array. If you have a virtual property and try $this->virtualProp = something, PHP throws a runtime error. It’s not an IDE warning. It’s a hard fail.
Third, serialization behaves differently for virtual properties. Since there’s no backing value, serialize() and json_encode won’t include them unless you handle it explicitly. If you’re passing these objects across a queue or cache boundary, test that your data survives the round trip.
None of these are dealbreakers. You just won’t find them in the RFC. You find them at 11pm when your queue worker silently drops a property.
Should you actually migrate?
If you’re already on PHP 8.3, the jump to 8.4 is painless. The property hooks RFC passed with overwhelming support, and the implementation is stable. I haven’t hit a single runtime bug in three weeks of production use.
If you’re on 8.1 or 8.2, you have a bigger jump but also more to gain: enums, fibers, readonly properties, intersection types, and now hooks and asymmetric visibility all stack up into a language that feels substantially different from the PHP 7.x era.
I wouldn’t rewrite existing getters and setters just for the sake of it, at least not all at once. But for new code, I’ve stopped writing traditional getters entirely. Property hooks are cleaner, the behavior is explicit, and the code lives where you’d actually look for it.
PHP has been quietly stacking wins since 8.1. Enums, readonly classes, typed constants, and now property hooks. Each version makes the language a bit tighter without breaking what came before. I wrote about React doing something similar with its compiler a few days ago. Same energy: less boilerplate, more meaning at the declaration site.
If you want to try property hooks this week, spin up a fresh Laravel 12 project on PHP 8.4 and rewrite one DTO. You’ll know within an hour whether the pattern clicks for you.
The PHP 8.4 property hooks RFC has the full spec, and the migration guide covers everything else that shipped. I’d read both before touching production code. I write about this kind of language and framework evolution on my site if you want more of this.