Skip to content

PHP 8.4 Property Hooks: What I Actually Use in Production

PHP 8.4 Property Hooks: What I Actually Use in Production

I almost wrote a getter for the eighth time on a Tuesday in February and then remembered I didn’t have to. That was the moment PHP 8.4 property hooks finally clicked for me. Not when I read the RFC, not when I watched a conference talk about them, not when I tried them on a toy class. It clicked because I caught myself doing the old thing out of habit, deleted the method, and replaced it with three lines that read more like English than code.

That was a year ago now. I’ve been writing property hooks in real Laravel apps since shortly after PHP 8.4 dropped, and I have opinions. They’ve cleaned up two things I used to wave away as “fine, that’s just how PHP is.” They also have at least one wart I keep tripping on. Let me show you both.

What a property hook actually is

Quick refresher in case you skipped the changelog. Property hooks let you attach get and set logic directly to a property without writing a separate method. The class still exposes a plain property to callers; the hook runs when someone reads or writes it.

Here’s the old pattern I shipped for years:

class Order
{
    private string $status = 'pending';

    public function getStatus(): string
    {
        return strtoupper($this->status);
    }

    public function setStatus(string $value): void
    {
        $value = strtolower(trim($value));
        if (!in_array($value, ['pending', 'paid', 'shipped', 'cancelled'], true)) {
            throw new \InvalidArgumentException("Bad status: $value");
        }
        $this->status = $value;
    }
}

// Usage
$order->setStatus('Paid');
echo $order->getStatus();

Same intent with a property hook:

class Order
{
    public string $status = 'pending' {
        get => strtoupper($this->status);
        set (string $value) {
            $value = strtolower(trim($value));
            if (!in_array($value, ['pending', 'paid', 'shipped', 'cancelled'], true)) {
                throw new \InvalidArgumentException("Bad status: $value");
            }
            $this->status = $value;
        }
    }
}

// Usage
$order->status = 'Paid';
echo $order->status;

Callers stop caring whether the thing on the other side is a field or a behavior. That is the whole point. The official RFC has the formal grammar if you want it. I’ll spare you the BNF and just show you what I actually do with this.

The get hook that replaced my computed accessors

The first place hooks paid for themselves was on read-only derived values. Things like a full name on a Person, or a formatted total on an Invoice. I used to have a getFullName() method, then I’d half-remember whether I’d already aliased it in the view, then I’d grep, and you can guess how that went.

class Person
{
    public function __construct(
        public readonly string $first,
        public readonly string $last,
    ) {}

    public string $fullName {
        get => trim("{$this->first} {$this->last}");
    }
}

$p = new Person('Ada', 'Lovelace');
echo $p->fullName; // "Ada Lovelace"

No backing field, no setter, no method. The hook owns the value. This is the form I reach for the most. It plays surprisingly well with Eloquent because Laravel’s magic property access falls through to the actual property access path, not to a method call. Views and JSON responses see $person->full_name (after a small \Stringable or accessor tweak) without me wiring up an extra accessor.

One detail that bit me early: a get-only hook makes the property effectively read-only from outside. If you try $person->fullName = 'x' you get a fatal Property cannot be written. That is correct, but the error came from a serializer that was helpfully trying to round-trip my object, and I had to teach it to skip hook-only properties. I wrote about a related Eloquent serialization issue in my notes on PHP 8.4 asymmetric visibility, which complements hooks more than I expected.

The set hook and where validation finally feels right

The second place hooks earned a permanent slot in my toolbox is small input validation that doesn’t deserve a full Form Request or a value object. Things like trimming whitespace or clamping a percentage to 0..100. I used to either inline that in a constructor, or write a setter and watch the constructor and the setter slowly disagree.

class CouponCode
{
    public string $code {
        set (string $value) {
            $value = strtoupper(trim($value));
            if (!preg_match('/^[A-Z0-9]{4,20}$/', $value)) {
                throw new \InvalidArgumentException("Bad code: $value");
            }
            $this->code = $value;
        }
    }

    public function __construct(string $code)
    {
        $this->code = $code;
    }
}

Notice that the constructor assignment goes through the hook. That is important. The contract becomes “this property is always trimmed, uppercased, and matches the regex,” and you don’t have to remember to call a separate normalize step. I shipped two bugs in 2023 that were exactly this: constructor assigned the raw value, setter normalized it, code paths drifted. With a set hook, those bugs can’t happen the same way.

If you want the hook to write to a different backing field, that works too:

class User
{
    public string $email {
        set (string $value) {
            $this->email = strtolower(trim($value));
        }
    }
}

Assigning to $this->email inside the hook writes the actual storage. Inside a hook, the property name refers to the underlying slot, not to itself recursively. The first time I read that sentence I had to read it twice. The first time I wrote it in code I assumed infinite recursion. It does not infinite-recur. Trust the engine.

Where I still reach for a real method

Hooks are a hammer that wants to be every nail. They are not. Two cases where I still write a plain method.

The first is anything that involves I/O. If “reading” the value triggers a database call or hits an external API, a hook is a lie. The property looks free, the call is not. I had a junior on the team add a get hook that fanned out to three Eloquent relations on every render. The Blade template hit it twelve times per row. We caught it because Laravel Pulse lit up like a Christmas tree, which is partly why I keep Pulse on in production even when the overhead annoys me. A method named loadProfileSummary() would have raised the right alarm in code review. A property called profileSummary did not.

The second case is when the value genuinely needs arguments. Hooks read like attributes, so they cannot take parameters. If your getter is getStatusInLocale($locale), that’s a method. Don’t get cute.

There is also a smaller gotcha worth mentioning: hooks do not compose with __get and __set the way you’d expect. If your class already has a magic property layer, hooked properties resolve first, and the magic layer never sees them. That is almost always what you want, but if you have a base class doing tracking via __set, hooked subclasses silently skip the tracking. I learned this by losing two days to a change log that mysteriously stopped recording certain fields. Read the PHP 8.4 release notes section on hooks before you mix them with magic methods.

How this changed the Laravel models I am writing now

This part is more opinion than fact. With hooks, my Eloquent models have fewer mutator and accessor methods and more typed public properties with small embedded normalization. The Laravel team added native hook awareness in 11.x so you don’t need a custom cast for the common cases anymore. I still cast JSON columns and money the old way because casts have a clean serialization story and hooks do not, but for “trim this, lowercase that, derive the other thing,” I write a hook.

Two practical rules I follow now.

If the logic is more than five lines, it goes in a method or a value object. Hooks are for one-liners and tight validations, not for business logic that wants tests.

If the property is hot, read in a tight loop or hit constantly in templates, I benchmark before and after. Hooks have a tiny per-call cost that is invisible in normal code and very visible in a 100k-row export. I documented one of those benchmarks in a Pest vs PHPUnit post earlier this year, and the same approach works here.

If you want to see how I bake this into the work I ship for clients, my recent project notes on abrarqasim.com/work include a couple of refactors where moving five accessor methods to hooks shrank a model from 220 lines to 140 with no behavior change. Not a benchmark, but a real “this code is easier to read on Monday morning” win.

One thing to try this week

Pick one model in your codebase with two or more getX() or setX() methods that do trivial work. Turn each one into a hook. Run your tests. Read the diff. If it looks better, commit. If it looks worse, and there are real cases where it does, especially if your IDE’s navigation doesn’t follow hooks well yet, revert. You’ll have a much clearer feel for where hooks belong in your code than any blog post can give you. Including this one.