Skip to content
ArticlePHP

PHP 8.4 Property Hooks: The Feature That Changes Everything

D

Dinesh Wijethunga

April 27, 2026Reviewed Jul 10, 2026 7 min readIntermediate 5,639 views
ShareX / TwitterLinkedIn
PHP 8.4 Property Hooks: The Feature That Changes Everything

The Boilerplate Problem

For twenty years, PHP developers have written the same ritual: make the property private, write a getter, write a setter, repeat for every field. Not because the design demanded it — but because it was the only way to reserve the right to add validation or transformation later without breaking every caller.

// The old way — 15 lines to guard one string
class User
{
    private string $email;

    public function getEmail(): string
    {
        return $this->email;
    }

    public function setEmail(string $email): void
    {
        if (! filter_var($email, FILTER_VALIDATE_EMAIL)) {
            throw new InvalidArgumentException("Invalid email: {$email}");
        }

        $this->email = strtolower($email);
    }
}

PHP 8.4's property hooks (from the property hooks RFC) end that ritual. The logic moves onto the property itself, and callers use plain property syntax:

// The 8.4 way
class User
{
    public string $email {
        set {
            if (! filter_var($value, FILTER_VALIDATE_EMAIL)) {
                throw new InvalidArgumentException("Invalid email: {$value}");
            }

            $this->email = strtolower($value);
        }
    }
}

$user = new User();
$user->email = '[email protected]';  // stored as '[email protected]'
$user->email = 'not-an-email';        // throws InvalidArgumentException

Inside a set hook, the incoming value is available as $value automatically. No getter, no setter, no private backing field to manage — and if a class never needs hooks, it never pays for them. You can add a hook to a plain public property years later without breaking a single caller. That's the part that changes everything: getters-and-setters-just-in-case is no longer a defensible default.

The Four Hook Forms

Both hooks come in a block form and a short arrow form:

class Post
{
    // 1. get block — full body, return required
    public string $preview {
        get {
            return substr(strip_tags($this->content), 0, 160);
        }
    }

    // 2. get arrow — single expression
    public int $wordCount {
        get => str_word_count(strip_tags($this->content));
    }

    // 3. set block — you assign the backing value yourself
    public string $title {
        set {
            $this->title = trim($value);
        }
    }

    // 4. set arrow — the expression result IS the stored value
    public string $slug {
        set => strtolower(str_replace(' ', '-', trim($value)));
    }

    public string $content = '';
}

The arrow set form trips people up the first time: set => expr doesn't return in the usual sense — whatever the expression evaluates to is what gets written to the property. It's the concise form for normalisation (trimming, lowercasing, slugifying).

Virtual Properties: Computed Values with Property Syntax

If a get hook never touches the property's own backing value, PHP allocates no storage at all. The property is virtual — computed on every read:

class Invoice
{
    public function __construct(
        public int $amountCents,
        public string $currency = 'AED',
    ) {}

    // No stored value — recomputed on each access
    public string $formattedTotal {
        get => sprintf('%s %.2f', $this->currency, $this->amountCents / 100);
    }
}

$invoice = new Invoice(45900);
echo $invoice->formattedTotal;  // "AED 459.00"

Writing to a virtual property with no set hook throws an Error — exactly what you want for derived values.

Asymmetric Visibility: The Other Half of the Story

PHP 8.4 shipped a second, closely related feature (asymmetric visibility RFC): a property can now have a different visibility for reading and writing.

class Order
{
    public private(set) string $status = 'pending';

    public function markPaid(): void
    {
        $this->status = 'paid';   // fine — inside the class
    }
}

$order = new Order();
echo $order->status;       // fine — public read
$order->status = 'paid';   // Error: cannot modify private(set) property

Before 8.4 this required a private property plus a public getter. Now it's one declaration. protected(set) exists too, and you can combine visibility with hooks on the same property. Together, hooks and asymmetric visibility eliminate the two remaining reasons getter/setter pairs existed: transformation and write-protection.

Hooks in Interfaces

Interfaces can now demand properties, not just methods:

interface Sluggable
{
    public string $slug { get; }
}

class Article implements Sluggable
{
    public function __construct(public string $title) {}

    public string $slug {
        get => strtolower(str_replace(' ', '-', $this->title));
    }
}

Any class satisfies { get; } with either a hooked property or a plain public one. API contracts that used to force getSlug(): string methods can now speak in properties.

A Real Example: Self-Validating DTOs

Where hooks genuinely shine is data transfer objects. Here's a pattern from a ride-booking API — coordinates that are impossible to construct invalid:

final class FareEstimateRequest
{
    public float $lat {
        set {
            if ($value < -90.0 || $value > 90.0) {
                throw new InvalidArgumentException("Latitude {$value} is out of range.");
            }
            $this->lat = $value;
        }
    }

    public float $lng {
        set {
            if ($value < -180.0 || $value > 180.0) {
                throw new InvalidArgumentException("Longitude {$value} is out of range.");
            }
            $this->lng = $value;
        }
    }
}

Every service downstream can trust the object without re-validating. The invariant lives in exactly one place — on the property it protects.

The Laravel Caveat: Hooks Don't Work on Eloquent Attributes

This is the mistake every Laravel developer will make once, so let's make it now, in writing:

// This does NOT do what you hope
class Post extends Model
{
    public string $title {
        set => ucfirst($value);   // breaks Eloquent persistence!
    }
}

Eloquent attributes aren't real properties — they live in the model's internal $attributes array and are reached through __get/__set magic. Declaring a real (hooked) property with the same name shadows the attribute: your value never reaches $attributes, never gets saved, never appears in toArray().

The rule of thumb:

  • On models — keep using casts and Attribute accessors/mutators. That's the Eloquent-native mechanism.
  • Everywhere else — DTOs, value objects, form objects, services, enums-with-state — property hooks are now the cleanest tool in the box.

Gotchas Worth Knowing

  • readonly and hooks don't mix. A hooked property can't be declared readonly. If you want write-once semantics with public reads, use private(set) instead.
  • References need opt-in. You can't take a reference to a hooked property (or $obj->arr[] = ... through one) unless the class declares an explicit &get hook.
  • Performance is a non-issue. Properties without hooks are exactly as fast as before, and the engine inlines simple hooks. Don't let micro-benchmark anxiety keep you on getter pairs.

What Else Shipped in PHP 8.4

Property hooks headline the release, but 8.4 is dense:

  • array_find(), array_any(), array_all(), array_find_key() — the array searches you've been writing with array_filter(...)[0] ?? null for years:
    $firstAdmin = array_find($users, fn ($u) => $u->isAdmin());
  • new without wrapping parentheses — chain straight off instantiation:
    $slug = new Slugifier()->slugify($title);   // was: (new Slugifier())->...
  • An HTML5-compliant DOM parserDom\HTMLDocument::createFromString() finally parses real-world HTML without the libxml warnings ritual.
  • Lazy objects — native proxy/ghost object support via Reflection, which container and ORM authors (Doctrine, Symfony) are already using.
  • #[\Deprecated] attribute — deprecate your own methods and constants with engine-level warnings.
  • mb_trim() and friends, and a BcMath\Number object API with operator support.
  • Heads-up: implicitly nullable parameters (function f(Foo $x = null)) are deprecated — write ?Foo $x = null before 9.0 makes it fatal.

Should You Use Hooks Today?

Yes — with the Eloquent caveat above. New DTOs and value objects should use typed public properties, adding hooks only where an invariant or transformation exists, and private(set) where callers may read but not write. Existing code doesn't need a rewrite: the beauty of hooks is that you can introduce them property-by-property, later, without breaking call sites.

If you want to see typed DTOs carrying data through a real API, that's exactly how the request flow works in Build a REST API with Laravel 13 and Sanctum — and how every module is structured in the modular monolith that powers our ride-booking platform.

Questions or a different take? Drop a comment — I read every one.

Frequently Asked Questions

What are property hooks in PHP 8.4?
Property hooks are get and set blocks declared directly on a class property that run when the property is read or written. They replace getter/setter method boilerplate while keeping plain property syntax ($obj->email) at every call site.
Do PHP 8.4 property hooks work with Laravel Eloquent models?
No. Eloquent attributes live in the model's internal $attributes array and are accessed via __get/__set magic. Declaring a real hooked property with the same name shadows the attribute and breaks persistence. Use Eloquent casts and Attribute accessors on models, and property hooks on DTOs, value objects, and services.
Can property hooks be combined with readonly?
No — a hooked property cannot be declared readonly. For write-protection, use PHP 8.4 asymmetric visibility instead: public private(set) lets everyone read the property while only the class itself can write it.
D
Dinesh Wijethunga

Senior Full Stack Developer · Building SaaS products & teaching Laravel/React · 10+ years experience · Founder of Orion360 · Based in Dubai, UAE.

Was this post helpful?

Reviews & Ratings

Sign in to leave a review.

Comments(4)

Guest comments are held for moderation.

You might also like

Multi-Tenant RBAC in Laravel: Team-Scoped Permissions with Spatie and Teamwork
ArticleBeginnerLaravel

Multi-Tenant RBAC in Laravel: Team-Scoped Permissions with Spatie and Teamwork

A sys_admin should see everything. An agency subadmin should only see their agency's drivers. A fleet driver shouldn't manage vehicles at all. Implementing team-scoped RBAC with spatie/laravel-permission and mpociot/teamwork means permissions are checked against both role and team simultaneously — here's how it works.

D
Dinesh Wijethunga
3 months ago
4535m