- Book: Decoupled PHP — Clean and Hexagonal Architecture for Applications That Outlive the Framework
- Also by me: Thinking in Go (2-book series) — Complete Guide to Go Programming + Hexagonal Architecture in Go
- My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools
- Me: xgabriel.com | GitHub
You wrote a Money value object two years ago. Two constructor-promoted fields, both readonly, a final class on top. Nobody can touch the internals. You moved on.
Then a teammate needed "money with tax attached." They dropped the final off the class so they could extend it, added a TaxedMoney subclass, and redeclared $amount with a get hook that quietly multiplies by a rate. Your value object now lies about its own field. The invariant you thought readonly protected was never protecting that.
PHP 8.5, released in November 2025, adds one small thing that closes this: you can now mark a promoted constructor parameter final. Small rule. It removes a whole class of accidental override.
What final on a property actually means
final on a property is not new. PHP 8.4 introduced it alongside property hooks. A final property cannot be redeclared by a child class. No new hooks, no widened visibility, no shadowing. The declaration is sealed at the level that wrote it.
class Point
{
public final int $x;
public function __construct(int $x)
{
$this->x = $x;
}
}
class Shifted extends Point
{
// Fatal error: Cannot override final
// property Point::$x
public int $x {
get => $this->x + 10;
}
}
Without final, that child redeclaration is legal. The subclass replaces the field with a hooked version, and every reader of $point->x inside a Shifted gets the shifted value. final turns the attempt into a fatal error at compile time.
This is different from what readonly gives you. readonly stops re-assignment after the field is set. It says nothing about whether a subclass can redeclare the property to change how reads behave. Two separate guarantees. People reach for readonly and assume they bought both.
The gap: promotion and final couldn't mix
Here is the part that made this awkward before 8.5. You could put final on a normally-declared property. You could not put it on a promoted one.
// PHP 8.4 and earlier: fatal error
class Money
{
public function __construct(
final public readonly int $amount,
final public readonly string $currency,
) {}
}
So if you wanted a value object with sealed fields, you had two options. Write every field out longhand with an explicit constructor body, losing the whole point of promotion. Or seal the entire class with final class and hope nobody ever needs to extend it.
final class is the blunt instrument. It works right up until someone has a real reason to subclass, deletes the keyword, and every field underneath silently loses its protection at the same time. One edit, and the sealing on five fields evaporates.
PHP 8.5: final on the promoted param
Now the two combine. You seal the field, not the class.
// PHP 8.5
class Money
{
public function __construct(
final public readonly int $amount,
final public readonly string $currency,
) {}
}
class TaxedMoney extends Money
{
// Fatal error: Cannot override final
// property Money::$amount
public int $amount {
get => $this->amount * 2;
}
}
The class stays open. Someone can extend Money to add a withTax() method or a formatting helper. What they cannot do is redeclare $amount and change what it means. The field-level contract survives the subclass.
That is the accidental-override case from the top of this post, gone. The teammate can still build TaxedMoney. They just can't do it by lying about $amount.
final defaults to public
One quirk worth knowing. A final property does not require an explicit visibility. Leave it off and it defaults to public.
class Money
{
public function __construct(
final readonly int $amount, // public
final readonly string $currency, // public
) {}
}
Both fields are public here. If you like your visibility spelled out, keep writing final public. A code-style rule can enforce that across the team. I prefer the explicit form in shared code, because "why is this public" is a question you want answered in the declaration, not inferred from a language default.
You can still combine final with everything else 8.4 gave you: asymmetric visibility and property hooks both compose with it.
class Order
{
public function __construct(
final public private(set) OrderStatus $status,
) {}
}
Read from anywhere, write only from inside Order, and no subclass can redeclare the field to loosen either rule. That is a fully locked domain field in one line.
Why readonly alone was never enough
Since PHP 8.4, readonly properties are implicitly protected(set) instead of private(set). The change was deliberate and useful: child classes can now initialize a readonly property their parent left unset. But it also means a subclass scope has more reach into a readonly field than it did on 8.1 through 8.3.
Combine that with redeclaration, and "readonly value object" stops being the airtight phrase it sounds like. Readonly guards the write-once semantics. It does not guard the shape of the declaration against a subclass. Those are the two halves, and before 8.5 you could only get the second half by sealing the whole class.
final readonly on a promoted field gives you both at the granularity of a single property:
-
readonly— set once, no reassignment. -
final— no subclass may redeclare or reshape it.
That is the pair you actually meant when you said "this field is locked."
final property vs final class
They answer different questions.
final class means "this type has no subclasses." It is a statement about the hierarchy. Use it when the class genuinely should not be extended, which is a good default for most value objects and DTOs.
final on a property means "this field's declaration is sealed, even if the class is extended." It is a statement about one member. Use it when the class is legitimately open for extension but specific fields carry invariants that no subclass should be allowed to touch.
The new promotion support matters most for the second case. Framework base classes, extensible domain models, library types meant to be subclassed. Anywhere you keep the door open on purpose but still want certain fields nailed shut.
When to reach for it
Not every promoted field wants final. A request DTO that lives for one HTTP call and never gets subclassed does not need it. Sealing the class with final class covers that case with less noise.
Reach for final on the promoted param when both are true: the class is open for extension, and the field carries meaning a subclass must not redefine. A Money amount. An aggregate's identity. A status field guarded by a transition rule. Fields where a redeclared get hook in a subclass would be a bug you'd never think to test for.
The rule is small. It costs one keyword. What it buys is that a future subclass, written by someone who never read your invariants, fails at compile time instead of shipping a value object that quietly returns the wrong number.
Which of your value objects is a final class today only because you couldn't seal its fields any other way?
Sealing a field is a domain decision, and the cleanest place for that decision is on the type that owns the concept, not scattered across the framework layer that happens to build it. Keeping value objects honest, so their invariants survive a subclass or a framework swap, is the thread that runs through clean and hexagonal architecture. If this post's take on locking domain fields resonated, Decoupled PHP is the longer-form version, with chapters on value objects, aggregates, and the boundary between domain code and the framework around it.
Available on Kindle, Paperback, and Hardcover. English, German, and Japanese editions out now — Portuguese and Spanish coming soon.

Top comments (0)