PHP 8.4 Property Hooks Are the Biggest OOP Change in Years. Here’s Every Pattern You Need.

Getters and setters as we know them are obsolete. Property hooks and asymmetric visibility — shipped November 21, 2024 — change how PHP handles object state permanently.

Fifteen years. That’s how long PHP developers have been writing getEmail() and setEmail() to control access to class properties. Five RFCs, spanning from 2009 to 2024. Multiple failed attempts. Two core developers spending months fighting for it.

On November 21, 2024, PHP 8.4 finally shipped property hooks — and with them, asymmetric visibility. The RFC that passed 42:2 after one of the most contentious debates in PHP Internals history.

This is the complete guide. Every hook form. Every asymmetric visibility pattern. The virtual property model. Interface hooks. What you can and can’t combine. And how all of it changes the way you write classes in Laravel applications right now.


What Problem Does This Actually Solve?

Start with a User class — the canonical example for a reason. You need an email property. You want to:

  • Validate it before storing
  • Normalise it to lowercase
  • Expose it publicly for reading
  • Prevent external code from writing raw strings directly

Before PHP 8.4, the only tools were:

// Option 1: Private property + methods (most common)
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);
    }
}

// Option 2: readonly (PHP 8.1) — immutable only, no validation
class User
{
    public function __construct(
        public readonly string $email
    ) {}
}

// Option 3: Magic __get / __set — type-unsafe, IDE-unfriendly, slow

None of these are great. Option 1 is 12 lines of boilerplate for one property. Option 2 can’t validate. Option 3 is a footgun.

PHP 8.4 gives you a fourth option that’s better than all three:

class User
{
    public string $email {
        set(string $value) {
            if (!filter_var($value, FILTER_VALIDATE_EMAIL)) {
                throw new InvalidArgumentException("Invalid email: {$value}");
            }
            $this->email = strtolower($value);
        }
    }
}

$user = new User();
$user->email = 'HELLO@EXAMPLE.COM';  // triggers the set hook
echo $user->email;                    // 'hello@example.com'

The property is publicly readable and writable — but the write goes through your validation logic. No getter method. No setter method. The logic lives with the property.


The Two Hooks: get and set

Every property can define a get hook, a set hook, or both. Neither is required — a property with no hooks behaves exactly like a normal PHP property.

The get hook

Overrides what happens when the property is read:

class User
{
    public function __construct(
        public string $firstName,
        public string $lastName,
    ) {}

    // Virtual property — computed, not stored
    public string $fullName {
        get => "{$this->firstName} {$this->lastName}";
    }
}

$user = new User('Sadique', 'Ali');
echo $user->fullName;  // 'Sadique Ali'

$fullName has no stored value. It’s computed on every read. Because no hook references $this->fullName, it becomes a virtual property — it has no backing value at all. You cannot write to it (there’s no set hook).

The set hook

Overrides what happens when the property is assigned:

class Invoice
{
    public float $amount {
        set(float $value) {
            if ($value <= 0) {
                throw new InvalidArgumentException('Amount must be positive.');
            }
            $this->amount = round($value, 2);  // stores rounded value
        }
    }
}

$invoice = new Invoice();
$invoice->amount = 99.999;
echo $invoice->amount;  // 99.99 — stored rounded, not 99.999

Inside the set hook, $value is the incoming value. Assigning to $this->amount stores it (bypassing the hook to prevent infinite recursion).

Short form (arrow syntax)

For single-expression hooks, use the arrow form:

class Product
{
    public string $slug {
        set => strtolower(preg_replace('/[^a-z0-9]+/i', '-', $value));
    }

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

Both Hooks Together: Validated + Transformed

The real power is combining both:

class BlogPost
{
    public string $title {
        get => ucwords($this->title);          // always display-formatted
        set {
            if (strlen($value) < 3) {
                throw new InvalidArgumentException('Title too short.');
            }
            $this->title = trim($value);       // store trimmed
        }
    }

    public string $slug {
        get => $this->slug;
        set => strtolower(preg_replace('/[^a-z0-9]+/i', '-', trim($value)));
    }
}

$post = new BlogPost();
$post->title = '  hello world  ';
echo $post->title;   // 'Hello World' — trimmed on store, ucwords on read
echo $post->slug;    // 'hello-world'

Virtual Properties

A property is virtual when its hook never references $this->propertyName. It has no backing storage — it computes its value from other properties:

class Order
{
    public function __construct(
        private array $lineItems = []
    ) {}

    // Virtual — no $this->total stored anywhere
    public float $total {
        get => array_sum(array_column($this->lineItems, 'price'));
    }

    // Virtual bidirectional — splits on set, joins on get
    public string $fullAddress {
        get => implode(', ', [$this->street, $this->city, $this->postcode]);
        set {
            [$this->street, $this->city, $this->postcode] = explode(', ', $value, 3);
        }
    }

    private string $street   = '';
    private string $city     = '';
    private string $postcode = '';

    public function addItem(string $name, float $price): void
    {
        $this->lineItems[] = ['name' => $name, 'price' => $price];
    }
}

$order = new Order();
$order->addItem('Widget', 14.99);
$order->addItem('Gadget', 29.99);
echo $order->total;  // 44.98 — computed live, no stored $total

$order->fullAddress = '12 Baker St, London, W1U 3BW';
echo $order->city;  // 'London'

Asymmetric Visibility: public private(set)

Property hooks control what happens on read/write. Asymmetric visibility controls who is allowed to write.

The syntax puts the set-visibility in parentheses before the type:

class BankAccount
{
    // Anyone can read $balance. Only this class can write it.
    public private(set) float $balance = 0.0;

    public function deposit(float $amount): void
    {
        if ($amount <= 0) throw new InvalidArgumentException('Positive amounts only.');
        $this->balance += $amount;  // OK — we're inside the class
    }

    public function withdraw(float $amount): void
    {
        if ($amount > $this->balance) throw new RuntimeException('Insufficient funds.');
        $this->balance -= $amount;  // OK — we're inside the class
    }
}

$account = new BankAccount();
echo $account->balance;      // OK — public read
$account->balance = 1000;   // ERROR — private(set)
$account->deposit(1000);    // OK — goes through the method

The shorthand

private(set) Type $property is shorthand for public private(set) Type $property:

class User
{
    private(set) string $role = 'member';  // public read, private write

    public function promoteToAdmin(): void
    {
        $this->role = 'admin';
    }
}

Three visibility combinations

class Example
{
    // Most common: public read, private write
    public private(set) string $id;

    // Public read, protected write (subclasses can write)
    public protected(set) string $status;

    // Protected read, private write (only public API is through methods)
    protected private(set) string $internalToken;
}

Constructor promotion with asymmetric visibility

Works exactly where you’d expect:

class Article
{
    public function __construct(
        public private(set) string $id,
        public private(set) string $title,
        public private(set) Carbon $createdAt,
    ) {}
}

$article = new Article('abc-123', 'Hello World', now());
echo $article->title;  // OK
$article->title = 'Changed';  // ERROR — private(set)

Combining Hooks and Asymmetric Visibility

These features are complementary, not mutually exclusive. You can put a hook on an asymmetrically visible property:

class PaymentRecord
{
    // Public read, private write, with validation on set
    public private(set) Money $amount {
        set(Money $value) {
            if ($value->isNegative()) {
                throw new InvalidArgumentException('Payment amounts must be positive.');
            }
            $this->amount = $value;
        }
    }

    // Computed from $amount, no external write possible
    public string $formatted {
        get => '$' . number_format($this->amount->value, 2);
    }
}

The rule: asymmetric visibility (private(set)) controls permission. Hooks control behaviour. They apply at different layers and compose cleanly.


Interface Hooks

Interfaces can now declare that implementing classes must provide hookable properties — and specify which hooks are required:

interface HasSlug
{
    // Implementing class must provide a publicly readable $slug
    public string $slug { get; }
}

interface Auditable
{
    // Must be readable and writeable through hooks
    public Carbon $updatedAt { get; set; }
}

class BlogPost implements HasSlug, Auditable
{
    public Carbon $updatedAt {
        get => $this->updatedAt;
        set { $this->updatedAt = $value; }
    }

    public string $slug {
        // Interface requires get — implementation can add set too
        get => strtolower(str_replace(' ', '-', $this->title));
        set => strtolower(preg_replace('/[^a-z0-9]+/', '-', $value));
    }

    public function __construct(public string $title) {}
}

An interface that requires { get; } does not prevent the implementing class from also defining a set hook. The interface declares the minimum contract. Implementations can be more permissive.


What You Cannot Combine

Two important incompatibilities to know upfront:

readonly + hooks — not allowed

class User
{
    public readonly string $id {
        get => 'user-' . $this->internalId;  // ❌ Parse error
    }
}

readonly properties cannot have hooks. If you need read-control behaviour, use private(set) instead of readonly.

static properties — not allowed

Hooks are instance-level. Static properties cannot have hooks.


Real-World Patterns for Laravel Applications

DTO with validation on construction

class CreateInvoiceData
{
    public string $clientName {
        set(string $value) {
            if (strlen(trim($value)) < 2) {
                throw new InvalidArgumentException('Client name too short.');
            }
            $this->clientName = trim($value);
        }
    }

    public float $amount {
        set(float $value) {
            if ($value <= 0) {
                throw new InvalidArgumentException('Amount must be positive.');
            }
            $this->amount = round($value, 2);
        }
    }

    // Virtual — computed from amount, no storage
    public string $formattedAmount {
        get => '$' . number_format($this->amount, 2);
    }

    public function __construct(string $clientName, float $amount)
    {
        $this->clientName = $clientName;  // triggers set hook
        $this->amount     = $amount;      // triggers set hook
    }
}

// Usage in a controller or action class:
$data = new CreateInvoiceData('Acme Corp', 2400.007);
echo $data->formattedAmount;   // '$2,400.01'
echo $data->clientName;        // 'Acme Corp'

Value object with private(set)

class Money
{
    public private(set) float $amount;
    public private(set) string $currency;

    public float $inPence {
        get => $this->amount * 100;
    }

    public function __construct(float $amount, string $currency = 'GBP')
    {
        $this->amount   = round($amount, 2);
        $this->currency = strtoupper($currency);
    }

    public function add(Money $other): static
    {
        if ($this->currency !== $other->currency) {
            throw new InvalidArgumentException('Currency mismatch.');
        }
        return new static($this->amount + $other->amount, $this->currency);
    }
}

$price = new Money(19.99);
echo $price->amount;    // 19.99 — readable
echo $price->inPence;   // 1999.0 — virtual
$price->amount = 0;     // ERROR — private(set)

The Migration Question: Do I Update Existing Code?

Honestly — not urgently.

Property hooks and asymmetric visibility are backward compatible. Your existing getters and setters still work. PHP won’t deprecate them. The case for migrating isn’t correctness; it’s clarity and maintainability.

Migrate when you’re already touching the class. If you’re adding a new property, use hooks from the start. If you’re refactoring a DTO, move to private(set) and drop the getter methods. Don’t open a file just to rewrite working getters.

Prioritise new DTOs and value objects. These are where the boilerplate was worst and where hooks deliver the most immediate clarity.

Keep Laravel Eloquent models mostly as-is. Eloquent uses magic __get/__set for attribute access internally, and property hooks sit at the PHP language level, not the Eloquent layer. You can use hooks on plain PHP classes you use alongside Eloquent (DTOs, value objects, form data objects) — and in fact Day 26 of this series covers exactly how property hooks interact with Eloquent model attributes. But don’t try to put hooks on $fillable or $casts — those are Eloquent-specific mechanisms that hooks don’t touch.


The Bigger Picture

PHP 8.4 is the most exciting PHP release in several years. Property hooks and asymmetric visibility are, according to their authors, the best thing since enums.

That’s not marketing copy. These features have been under active discussion since 2009. They passed only because the PHP Foundation funded the developers to finish them. The 42:2 vote tells you how good the final design is.

The PHP you write in 2026 is genuinely different from the PHP written in 2020. The type system is strong. Enums are real. Readonly properties exist. Fibers are in the runtime. Property hooks are shipped.

The language is evolving. The ecosystem — Laravel 13 running PHP 8.4, with first-class MCP support, native mobile via NativePHP, model attributes, Cloud CLI — is evolving with it.

The hardest part isn’t learning the syntax. The hardest part is unlearning the instinct to reach for getEmail() and setEmail() out of habit. Once you stop doing that, you won’t go back.


Follow me for daily deep-dives on Laravel, PHP, Vue.js, and AI integrations. New article every day.

Leave a Reply

Your email address will not be published. Required fields are marked *