PHP 8.4 Named Arguments and Property Hooks: Advanced Patterns Beyond the Basics

#Doctrine property hooks
Sandor Farkas - Founder & Lead Developer at Wolf-Tech

Sandor Farkas

Founder & Lead Developer

Expert in software development and legacy code optimization

Most teams that adopted PHP 8.4 used property hooks where the payoff is obvious: collapsing a getter and setter pair into one declaration, computing a display value, rejecting a negative number on write. That part is well documented, and our earlier post on property hooks in Symfony covers it.

The harder questions arrive later, usually the first time Doctrine property hooks meet a table with five years of history in it. Hydration does not go through your constructor. Validation you added last week can suddenly apply to a row written in 2021. And the same feature that makes a DTO pleasant to read can make an entity behave in ways that are difficult to trace from a stack trace.

How Doctrine property hooks behave during hydration

Doctrine has never used your setters. It hydrates entities through reflection, writing directly to properties so that loading a row does not trigger domain logic. With plain properties that is straightforward. With hooked properties it is not, because PHP 8.4 changed what a reflection write means.

Consider an entity with a guard on write:

final class Invoice
{
    public int $amountInCents {
        set(int $value) {
            if ($value < 0) {
                throw new \InvalidArgumentException('Amount cannot be negative.');
            }
            $this->amountInCents = $value;
        }
    }
}

In PHP 8.4, ReflectionProperty::setValue() runs the set hook. That is the correct default for most callers, and it is exactly wrong for an ORM. To write the backing value without invoking the hook, PHP 8.4 added a raw pair:

$property = new \ReflectionProperty(Invoice::class, 'amountInCents');

$property->setValue($invoice, -500);    // runs the hook, throws
$property->setRawValue($invoice, -500); // writes storage directly, no hook

Recent Doctrine ORM releases use the raw accessors for hydration, which is the behaviour you want. It also means a set hook is not a data integrity mechanism. It guards application code paths, and it does nothing at all for rows that arrive from the database. If you need the invariant enforced on existing data, it belongs in a migration and a database constraint, not in a hook.

Check which behaviour your version gives you before you rely on either. Pin the ORM version in a test that hydrates an entity with a value the hook would reject, and assert that hydration succeeds. When a future upgrade changes the write path, that test fails immediately instead of during a nightly import.

Virtual properties cannot be mapped

A hook without a backing store gives you a virtual property. The value is computed on read and nothing is stored:

public string $displayName {
    get => trim("{$this->firstName} {$this->lastName}");
}

This is a good fit for a read model or a serializer output field. It is not something Doctrine can map to a column, because there is no storage behind it to write to or read from. Annotate it as a column and you get an error at metadata compilation time, which is the friendly outcome. The unfriendly version is a virtual property that shadows a name Doctrine expects, so map explicitly and keep virtual properties out of the mapped set.

The same rule affects change tracking. Doctrine compares the values it hydrated against the values it finds at flush time. A virtual property has no hydrated value to compare against, so it never appears in a changeset. If a computed value needs to be queryable or sortable in SQL, store it in a real column and update it in a lifecycle callback or a domain method.

Hooks, readonly, and asymmetric visibility

Teams often reach for a hook to make a property write-once. That is not what hooks are for, and the engine will tell you so: a readonly property can define a get hook, but a set hook on a readonly property is rejected.

PHP 8.4 shipped a better tool for the actual requirement. Asymmetric visibility separates read access from write access:

final class Subscription
{
    public private(set) string $status;

    public function cancel(): void
    {
        $this->status = 'cancelled';
    }
}

Any caller can read $status. Only the class can write it. No hook, no setter, no accessor boilerplate. Combining asymmetric visibility with a set hook is legitimate when you want both a narrow write scope and normalisation on write, but reach for the visibility modifier first and add the hook only if the value genuinely needs transforming.

Named arguments instead of fluent builders

The other half of a modern PHP 8.4 API surface is named arguments, and the pattern they replace most usefully is the fluent builder. A builder exists mainly because a constructor with eight parameters is unreadable at the call site. Named arguments fix the readability problem without the extra class:

$request = new ReportRequest(
    from: new DateTimeImmutable('2026-01-01'),
    to: new DateTimeImmutable('2026-03-31'),
    currency: 'EUR',
    includeDrafts: false,
);

Every editor with PHP language server support completes those parameter names, and the object is valid the moment it exists. A fluent builder cannot promise that, because ->build() has to check at runtime whether you called every required step.

For immutable value objects, a single with() method covers what a chain of withX() methods used to:

public function with(
    ?DateTimeImmutable $from = null,
    ?DateTimeImmutable $to = null,
    ?bool $includeDrafts = null,
): self {
    return new self(
        from: $from ?? $this->from,
        to: $to ?? $this->to,
        currency: $this->currency,
        includeDrafts: $includeDrafts ?? $this->includeDrafts,
    );
}

One caveat that bites in review: null here means "leave unchanged", so this shape cannot express "set this field to null". If any field is genuinely nullable, use a sentinel object or keep a dedicated withoutX() method for that field. Pretending the ambiguity is not there produces a class of bug that is very hard to spot in a diff.

There is also an API contract question worth stating plainly. Once callers use named arguments, your parameter names are public. Renaming $includeDrafts to $withDrafts is a breaking change for every caller, in the same way renaming a public method is. Treat a parameter rename in a library or a shared package as a semantic version bump, and settle on names during code review rather than after release. This is one of the small things a code quality review catches cheaply and a support ticket catches expensively.

What hooks cost at runtime

The usual comparison is against __get and __set, and the two work quite differently. Magic methods only fire when a property is missing or inaccessible from the calling scope, so the engine performs a lookup, misses, and then dispatches to the magic method with recursion guards in place. Hooks are compiled as part of the property declaration, and the access site knows at compile time that a hook is involved.

Practically, that means replacing __get with hooks is usually a small win rather than a regression, and replacing a plain property with a hooked one costs something. Hooked property access is a call, not a memory read. In a hot loop over a hundred thousand rows, that shows up. In request handling code, it does not.

Two rules keep this from becoming a problem. Keep hook bodies short, ideally a comparison or an assignment, because anything longer is domain logic hiding in an accessor. And do not put a database call, an event dispatch, or a mutation log write inside a hook. Event sourced aggregates are the tempting case here: recording every mutation in a set hook looks elegant until hydration replays the entire history and the aggregate records itself being loaded. Record mutations in explicit domain methods where the intent is visible in the call stack.

Keeping static analysis useful

PHPStan 2.x understands hooked properties and will analyse the bodies, so a hook that returns the wrong type or reads an uninitialised backing value is caught at the level you already run. Two habits improve the signal.

Declare the backing type precisely rather than leaning on the hook to narrow it. A property typed ?string with a get hook that never returns null still reads as nullable to every caller and to the analyser. If it cannot be null after construction, type it string.

And avoid hooks that both write the backing store and mutate another property. Analysers track those cases poorly, reviewers track them worse, and the resulting coupling makes the class hard to refactor later. If you are working through a codebase where accessors have quietly grown into domain logic, that untangling is the bulk of what legacy code optimisation involves.

A practical adoption order

If you are introducing these features into an existing Symfony application, the low risk sequence is to start with DTOs and form objects, where nothing hydrates the object through reflection and a set hook is simply validation close to the data. Move to value objects next, using named arguments to retire builder classes. Leave Doctrine entities until you have a hydration test in place, and keep virtual properties out of mapped classes entirely.

That order matters because the failure modes get progressively less visible. A broken DTO fails in a unit test. A broken entity fails at three in the morning when an import job hits a row nobody has touched since 2021.

If you are planning a PHP 8.4 migration and want a second opinion on where hooks help and where they add risk, we do this work as part of custom software development and standalone reviews. Write to hello@wolf-tech.io or read more at wolf-tech.io.