I was cleaning up a UserProfile model on a Sunday afternoon and I finally deleted my last getFullName() method. Not because the feature changed. Because PHP 8.4 property hooks made the method pointless a year ago, and I’d been putting off the cleanup out of pure inertia.
That’s basically my honest take on property hooks after a year of shipping them: they’re good, I use them a lot now, and they haven’t been the revolution I was expecting when I first read the RFC. Some background. I’ve been writing PHP since around the time array_walk felt spicy. Property hooks are the biggest change to how I model data in PHP since maybe typed properties in 7.4. And now that I’ve had a year of them in production Laravel projects, small CLI tools, and one weird service I built for a client on FrankenPHP, I’ve got opinions. Some of them contradict what I said in early hot takes. That’s fine, I was wrong.
Property hooks in 30 seconds if you missed them
Before 8.4, if you wanted a computed value or a validated setter, you wrote a method. Or you wrote __get/__set and prayed nobody had to debug it two years later. Something like:
class UserProfile
{
public function __construct(
public string $firstName,
public string $lastName,
private string $email,
) {}
public function getFullName(): string
{
return trim("{$this->firstName} {$this->lastName}");
}
public function getEmail(): string
{
return $this->email;
}
public function setEmail(string $email): void
{
$this->email = strtolower(trim($email));
}
}
With hooks, the same class collapses into:
class UserProfile
{
public string $fullName {
get => trim("{$this->firstName} {$this->lastName}");
}
public string $email {
set(string $value) => strtolower(trim($value));
}
public function __construct(
public string $firstName,
public string $lastName,
string $email,
) {
$this->email = $email;
}
}
You can read $profile->fullName and it works. You can set $profile->email = '[email protected]' and it stores [email protected]. No method call, no magic method. The property hooks RFC covers the full grammar; I won’t rewrite it here. What matters is that hooks are real language syntax, not an override on __get, so IDEs and static analyzers actually understand them. That was the part that convinced me to try them in a real codebase.
What they actually replaced in my code
Not what I thought they would. I went in expecting to delete half my getters. In practice I kept most of the methods that do anything non-trivial and only reached for hooks in three specific spots.
The first is computed properties. Full names, display strings, formatted currency. Anything that was a one-liner sitting inside a method. Those are the cleanest wins because the method never carried its own weight anyway.
The second is normalizing setters. Emails go through strtolower(trim(...)). Phone numbers get stripped of everything that isn’t a digit or a plus sign. Slugs get lowercased. Before hooks, I’d either do that in the constructor and then hope nobody assigned the property again, or write a small setter method that half my code would forget to call. Now the normalization lives on the property. If you assign to $user->email, it gets normalized. There is no path around it. That felt like an actual bug fix, not just aesthetics.
The third is lazy defaults for optional fields. Avatar URLs are the canonical example: if the user hasn’t uploaded one, generate a Gravatar URL from their email. Sitting that logic on the property means the controller doesn’t have to remember to call a helper.
Everything else, like validation with side effects or methods that touch multiple properties or anything that could throw, stays as a real method. The moment a hook does something you might want to test in isolation, the abstraction leaks.
Where property hooks made things worse
Two spots I regret. Both are about clarity, not correctness.
First, debugging accessor stacks. If you dd($user) in a Laravel project and the model has three hook-backed virtual properties, the dump shows them mixed in with real properties. That’s normally fine. It stops being fine when a hook throws inside a serializer and the stack trace points to a line that just reads $this->fullName. You then have to remember the class has a hook, open the file, and match the property name to a get block. It’s a small tax. It adds up.
Second, code review turns weird. A junior on my team last spring assigned to a property in a service method and could not figure out why the stored value was different from what they’d assigned. The setter hook was doing exactly its job by normalizing the input, but the reviewer wasn’t reading the model, they were reading the service. The behavior was invisible at the call site. I now have a soft rule that setter hooks should be idempotent and side-effect-free, and I put a one-line comment above any hook that transforms input. Not because the language requires it, but because reviewers deserve to know.
There’s also a specific case I keep hitting where I want to distinguish “not set” from “explicitly null”. Hooks and isset() interact in ways that surprised me twice. I now write those cases as plain methods with an explicit sentinel value. Cleaner and I don’t have to think.
Asymmetric visibility is quieter but genuinely useful
The other 8.4 feature that landed on the same day and got less blog attention is asymmetric visibility. You can now write things like:
final class Order
{
public function __construct(
public private(set) string $id,
public private(set) \DateTimeImmutable $createdAt,
public int $totalCents,
) {}
}
$order->id is publicly readable, but only the class itself can assign it. Before, I’d either mark it private and expose a getId() method, or mark it public and add a comment praying nobody wrote to it. I did the second one more often than I care to admit.
Where this actually removed boilerplate for me: entity IDs, created-at timestamps, version numbers on aggregates, and status enums that should only change through domain methods like $order->markShipped(). Nothing revolutionary. Just a small amount of intent that used to require a comment now lives in the type signature.
The PHP 8.4 release notes list this alongside property hooks and it’s worth reading them together. They’re built to work as a pair.
The one gotcha I keep hitting
Hook methods aren’t real methods. That sounds obvious. It bites you when you’re refactoring.
Here’s the specific footgun. You have a base class with a get hook on a property. You extend it. You want to change the getter behavior in the child. You cannot just redeclare the property with a new hook, or you’ll get a redeclaration error if the parent property is already public. You have to either override the hook block on the property (which works if you redeclare the whole property including its type and hook) or move the logic into an overrideable method that the hook calls. I’ve settled on the second pattern for anything that might get subclassed, because the first one gets ugly fast when the parent property has a default value or is promoted through the constructor.
The other gotcha is that hooks don’t compose with readonly the way I expected. A readonly property can’t have a set hook. That’s by design, but I have twice written a set hook and then added readonly for immutability and been confused for ten minutes until I remembered.
None of this is a reason to avoid hooks. It’s just the kind of thing you only learn by shipping them and then trying to refactor. My rule now is: if a property might be overridden in a subclass, use a plain property plus a method. If it won’t, use a hook.
What I’d tell a team adopting them today
Three things, in order.
Use hooks for computed properties and normalizing setters. Delete the corresponding methods. This is the highest-ratio change and it’s mechanical enough that you can do a whole model in ten minutes.
Use public private(set) for anything with domain-controlled writes. IDs, timestamps, status fields. Do it in the constructor as promoted parameters and you get the whole thing in one line. If you’re already on 8.4 for other reasons, this is basically free cleanup. I wrote about the same “I finally deleted the loop” feeling with PHP 8.4’s array_find and this is the visibility-modifier equivalent.
Keep methods for anything with side effects, anything that could throw, and anything you might want to test in isolation. Hooks are for describing the shape of your data, not for hiding behavior. If the code inside your set block calls a service or writes a log, that’s a method wearing a wig.
One more thing that isn’t about hooks but comes up whenever I write about PHP: this stuff works better when the rest of the framework is already on a modern version. Laravel 12 assumes 8.4 as the floor now, and if you’re stuck on an older major, half of what I’ve described here is inaccessible or awkward. If you want the full walkthrough on that, I did a write-up on what actually changed in Laravel 12 that leans on 8.4 features throughout.
If you want to see what a modern PHP codebase looks like when the whole stack is aligned, most of my recent client work is running on this shape: 8.4, Laravel 12, hooks where they earn it, methods where they don’t.
Do this today: pick one model class in your codebase that has a getFullName-style method and a setEmail-style setter. Rewrite it with hooks. Run your tests. If nothing breaks, you now know what the pattern feels like in your codebase and you can decide whether to keep going.
That’s how I did it. Slowly, one model at a time. A year later I’m not sure I’d write PHP any other way.