DEV Community

Cover image for PHP 8.4 Asymmetric Visibility: The Feature That Finally Lets You Build Real Value Objects
Gabriel Anhaia
Gabriel Anhaia

Posted on

PHP 8.4 Asymmetric Visibility: The Feature That Finally Lets You Build Real Value Objects


You have written this class. Maybe last week, maybe a hundred times.

final class Money
{
    public function __construct(
        public readonly int $amountInMinorUnits,
        public readonly string $currency,
    ) {}

    public function add(self $other): self
    {
        return new self(
            $this->amountInMinorUnits + $other->amountInMinorUnits,
            $this->currency,
        );
    }
}
Enter fullscreen mode Exit fullscreen mode

It works. PHPStan is happy. The reviewers wave it through. Then the first feature lands that needs to adjust the amount inside the class: a rounding step, or a lazy-loaded computed total. And you stop. readonly says nobody writes this, including you. The property is sealed from the outside and from the inside. You factor the change as "build a new Money and replace the reference," which mostly works, except the references live in three aggregates and one of them is a Cart you do not want to rebuild from scratch.

So you delete readonly. The property becomes public int $amountInMinorUnits. Any caller in any controller in any package can now reach in and rewrite the amount. The invariant that the constructor was enforcing — "non-negative, ISO currency, mutually consistent" — is now a polite suggestion.

PHP 8.4 closes this gap. The feature is asymmetric visibility. The property declares a read visibility and a separate write visibility, and the language enforces both. Outside the class, it is public. Inside the class, it is whatever you said. The hack everybody used to write (public read, magic-method or clone for write) is no longer the only option.

This post walks through what changed, the exact syntax (verified against the PHP RFC and the PHP 8.4 release), and the shape value objects take when you adopt it. The examples are real, runnable PHP 8.4.

The syntax, exactly as the RFC defines it

The form, from the asymmetric visibility RFC:

{read visibility} {set visibility}(set) {propertyType} $name;
Enter fullscreen mode Exit fullscreen mode

The set visibility is always followed by the literal token (set). The read visibility may be omitted, in which case it defaults to public. Three rules the engine enforces:

  1. The write visibility must be equal to or stricter than the read visibility. public private(set) is legal. private public(set) is not.
  2. The property must have a type declaration. There is no asymmetric visibility on untyped properties.
  3. You cannot combine readonly with explicit asymmetric visibility. The readonly modifier already implies private(set); mixing them is a parse error.

The three forms that matter day to day:

final class Account
{
    public private(set) string $email;
    public protected(set) int $loginCount;
    private(set) DateTimeImmutable $createdAt;
}
Enter fullscreen mode Exit fullscreen mode

public private(set) string $email reads as: anyone can read $email. Only methods on Account itself can write it. public protected(set) int $loginCount opens the write side to subclasses. The third line drops the explicit public. The read side defaults to public when omitted, so private(set) DateTimeImmutable $createdAt is exactly the same as public private(set) DateTimeImmutable $createdAt.

Outside the class, the property behaves like a public read-only field. Inside the class, the property is a regular mutable field, governed by whatever write visibility you picked.

$account = new Account('a@b.com');
echo $account->email;       // works
$account->email = 'c@d.com'; // Error: Cannot modify private(set) property
Enter fullscreen mode Exit fullscreen mode

That error is a runtime Error, not a TypeError and not a silent failure. The engine refuses the write.

What this fixes that readonly did not

readonly (PHP 8.1) is the strictly stronger constraint: once the constructor finishes, the property is frozen for every reader, including the class itself. That is great when the value really is immutable for the object's lifetime: an OrderId, a CustomerId, a Currency. It is wrong when the class wants to evolve its state internally while still refusing external writes.

Three cases that readonly could not express and asymmetric visibility now can:

Lazily computed properties. A Cart total that takes a non-trivial pass over line items (cached after first read). With readonly, you cannot write the cache; you keep a separate private field and add a getter. With asymmetric visibility, the cached total is the field.

final class Cart
{
    public private(set) ?Money $total = null;

    public function __construct(
        private array $lines,
    ) {}

    public function total(): Money
    {
        if ($this->total === null) {
            $this->total = $this->compute();
        }
        return $this->total;
    }

    private function compute(): Money { /* ... */ }
}
Enter fullscreen mode Exit fullscreen mode

The total reads as a public property to anyone outside Cart, but only Cart itself ever assigns it. Readers see one field. Writers go through Cart.

State machines that move forward. An Order whose status field changes from placed to paid to fulfilled. The transitions are business operations on the aggregate. Before 8.4, you either made the field public readonly (and rebuilt the whole order on every transition, which is painful for aggregates with line items and addresses), or you made it plain public and prayed nobody wrote $order->status = OrderStatus::Cancelled directly. Now:

final class Order
{
    public private(set) OrderStatus $status;
    public private(set) ?DateTimeImmutable $paidAt = null;

    public function __construct(
        public readonly OrderId $id,
        public readonly CustomerId $customerId,
    ) {
        $this->status = OrderStatus::Placed;
    }

    public function markPaid(DateTimeImmutable $at): void
    {
        if (!$this->status->canPay()) {
            throw new \DomainException(
                "Cannot pay an order in status {$this->status->value}"
            );
        }
        $this->status = OrderStatus::Paid;
        $this->paidAt = $at;
    }
}
Enter fullscreen mode Exit fullscreen mode

id and customerId keep readonly, because they really are frozen at construction. status and paidAt use private(set) because the business rules drive every change, and no caller can shortcut the rule.

Validated mutation. A value object that supports controlled in-place change. You used to pick between rebuilding a new instance every time, or trusting every caller not to touch the field. Asymmetric visibility makes a third option real: mutable, but only through methods that re-validate.

final class EmailAddress
{
    public private(set) string $value;

    public function __construct(string $value)
    {
        $this->assign($value);
    }

    public function change(string $value): void
    {
        $this->assign($value);
    }

    private function assign(string $value): void
    {
        if (!filter_var($value, FILTER_VALIDATE_EMAIL)) {
            throw new \InvalidArgumentException(
                "Invalid email: {$value}"
            );
        }
        $this->value = $value;
    }
}
Enter fullscreen mode Exit fullscreen mode

Same field, same invariant, two entry points (constructor and change), one private assignment routine that owns the rule.

Asymmetric visibility: public reads, restricted writes

Side by side: the same Money on 8.3 and on 8.4

This is the example that prompts the post. A Money value object that wants to support an internal adjust operation — say, rounding to the nearest minor unit after a tax calculation — without exposing the amount field for the whole world to clobber.

The 8.3 version, with readonly, cannot do this directly. The amount is sealed.

// PHP 8.3
final class Money
{
    public function __construct(
        public readonly int $amountInMinorUnits,
        public readonly string $currency,
    ) {
        $this->guard();
    }

    public function withRoundedAmount(int $precision): self
    {
        $rounded = $this->roundTo($precision);
        return new self($rounded, $this->currency);
    }

    private function guard(): void { /* ... */ }
    private function roundTo(int $p): int { /* ... */ }
}
Enter fullscreen mode Exit fullscreen mode

withRoundedAmount returns a new instance. Fine for one-off rounding, painful when the rounded Money is owned by an aggregate that holds a hundred of them and now needs to know about the swap.

The 8.4 version, with public private(set):

// PHP 8.4
final class Money
{
    public private(set) int $amountInMinorUnits;
    public private(set) string $currency;

    public function __construct(
        int $amountInMinorUnits,
        string $currency,
    ) {
        $this->assignAmount($amountInMinorUnits);
        $this->currency = $currency;
    }

    public function roundTo(int $precision): void
    {
        $factor = 10 ** $precision;
        $this->assignAmount(
            (int) (round($this->amountInMinorUnits / $factor) * $factor)
        );
    }

    public function add(self $other): self
    {
        $this->assertSameCurrency($other);
        return new self(
            $this->amountInMinorUnits + $other->amountInMinorUnits,
            $this->currency,
        );
    }

    private function assignAmount(int $amount): void
    {
        if ($amount < 0) {
            throw new \InvalidArgumentException(
                'Money amount cannot be negative.'
            );
        }
        $this->amountInMinorUnits = $amount;
    }

    private function assertSameCurrency(self $other): void
    {
        if ($this->currency !== $other->currency) {
            throw new \DomainException(
                "Currency mismatch: {$this->currency} vs {$other->currency}"
            );
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

The amount field is publicly readable. Any caller can do $money->amountInMinorUnits and get the int. No caller can do $money->amountInMinorUnits = -50 and corrupt the invariant. The constructor now enforces non-negative amounts, matching the invariant we mentioned in the opening. The roundTo method mutates the field in place, but every write goes through assignAmount, which checks the rule. The constructor and the mutator share the same validation path. The currency is sealed the same way: external code reads it, only Money methods write it.

You may notice this design treats add and roundTo differently. add produces a new Money. roundTo mutates the existing one in place. That is your domain's call. The language stays out of it. The point is that 8.4 lets you make the call deliberately.

If you want the older immutable shape, you can still use readonly for fields that truly never change after construction:

final class OrderId
{
    public function __construct(
        public readonly string $value,
    ) {
        if (!\Ramsey\Uuid\Uuid::isValid($value)) {
            throw new \InvalidArgumentException(
                "OrderId must be a valid UUID, got '{$value}'."
            );
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

OrderId is the classic value object: identity, frozen forever, equality by value. Keep it readonly. The new asymmetric form is for the cases that fell between readonly and plain public.

Where the feature pulls its weight: aggregates with private state

The pattern shows up most clearly in aggregates — the root entities of your domain that own a cluster of value objects and enforce rules across them. An aggregate's whole job is to expose state for reading while gating every write through a business method.

Before 8.4, the canonical PHP shape was:

// PHP 8.3
final class Subscription
{
    private SubscriptionStatus $status;
    private ?DateTimeImmutable $cancelledAt = null;

    public function __construct(
        public readonly SubscriptionId $id,
    ) {
        $this->status = SubscriptionStatus::Active;
    }

    public function status(): SubscriptionStatus { return $this->status; }
    public function cancelledAt(): ?DateTimeImmutable { return $this->cancelledAt; }

    public function cancel(DateTimeImmutable $at): void
    {
        if ($this->status !== SubscriptionStatus::Active) {
            throw new \DomainException('Only active subscriptions can be cancelled.');
        }
        $this->status = SubscriptionStatus::Cancelled;
        $this->cancelledAt = $at;
    }
}
Enter fullscreen mode Exit fullscreen mode

Every readable piece of state needs a getter method. The class fills up with status(), cancelledAt(), paidAt(), trialEndsAt(), on and on. Each getter is a one-liner. The noise adds up. Half the aggregate is method declarations whose only job is to return a field.

The 8.4 shape:

// PHP 8.4
final class Subscription
{
    public private(set) SubscriptionStatus $status;
    public private(set) ?DateTimeImmutable $cancelledAt = null;

    public function __construct(
        public readonly SubscriptionId $id,
    ) {
        $this->status = SubscriptionStatus::Active;
    }

    public function cancel(DateTimeImmutable $at): void
    {
        if ($this->status !== SubscriptionStatus::Active) {
            throw new \DomainException('Only active subscriptions can be cancelled.');
        }
        $this->status = SubscriptionStatus::Cancelled;
        $this->cancelledAt = $at;
    }
}
Enter fullscreen mode Exit fullscreen mode

The getters are gone. The state is publicly readable as fields, and PHP refuses external writes. cancel still owns the rule. Calling code that used to read $sub->status() now reads $sub->status. grep -R '->status()' and rip the parens off.

There is a real ergonomic gain in the consumer code too:

if ($subscription->status === SubscriptionStatus::Active) {
    $subscription->cancel($clock->now());
}
Enter fullscreen mode Exit fullscreen mode

versus:

if ($subscription->status() === SubscriptionStatus::Active) {
    $subscription->cancel($clock->now());
}
Enter fullscreen mode Exit fullscreen mode

Both are fine. The first is what aggregates have looked like in C#, Kotlin, and Swift for years. Asymmetric visibility is what brings PHP into that idiom.

A value object with public read access and private write. The compiler now enforces what comments used to

Things to watch for

A handful of edges the RFC and the manual call out, worth knowing before you refactor a domain layer:

  • Cannot stack with readonly. As above. If a field needs both behaviours, readonly already gives you private(set) implicitly — pick readonly for true immutability, asymmetric visibility for controlled mutation.
  • Property hooks interact. PHP 8.4 also added property hooks (get/set blocks inside the property declaration). If you write a set hook and an asymmetric set visibility on the same property, the visibility check runs first; if it passes, the hook runs. The combination is legal and the order is well-defined, but it is a lot of machinery on one field — reach for it only when both pieces earn their place.
  • References are still references. private(set) blocks direct assignment. It does not freeze the contents of an array or object held by the property. A public private(set) array $tags lets external callers do $obj->tags[] = 'new' if they get a reference to the array. For deep immutability, either use scalar/value-object fields or wrap mutable state behind explicit methods. (The RFC discusses this; the behaviour is the same as readonly properties holding arrays.)
  • Reflection sees both visibilities. ReflectionProperty gained methods to inspect the read and write modifiers separately. Serialisers, hydrators, and ORMs that rely on reflection will need updates that understand the split. Doctrine and the major frameworks have been tracking the RFC; expect runtime compatibility on the latest minor versions and double-check on older ones.
  • new self() still works inside the class. When you produce a new instance from a method on the same class, you can pass through the constructor as normal. Private-set visibility applies to property assignment, not to constructor calls — your factories and new self() calls keep working.

What changes in a Decoupled PHP codebase

If you write hexagonal PHP with a clean domain layer, this is the upgrade that closes a long-running pothole. The pattern in Decoupled PHP is: aggregates own their invariants, value objects validate at construction, the domain layer reveals nothing about persistence. Asymmetric visibility removes the last piece of friction in that pattern — the getter wall in front of every readable field.

Migration on an existing codebase, in three passes:

  1. Identify the aggregates and value objects. They live under Domain/. They have constructors that throw on invariant violations and methods named for business operations.
  2. Promote the obvious cases. Any private $field paired with a getter field(): T becomes public private(set) T $field. Delete the getter. Run the test suite. Greps for ->field() become ->field.
  3. Decide on the borderline cases. Fields that genuinely never change after construction stay readonly. Fields that change through business methods become private(set). Fields that should never have been internal in the first place become plain public. The decision is now linguistic; the language enforces it.

Upgrade your CI to 8.4 and start deleting getters.


More on the aggregate patterns

The hexagonal-PHP layout, the aggregate patterns above, and the rest of the domain-modelling toolkit get the long-form treatment in Decoupled PHP. It is written for engineers who already ship PHP for a living and want their domain code to outlast the framework du jour. Asymmetric visibility is one of the language features the book uses; the rest is the discipline around it.

Decoupled PHP — Clean and Hexagonal Architecture for Applications That Outlive the Framework

Available on Kindle, Paperback, and Hardcover. English, German, and Japanese editions out now — Portuguese and Spanish coming soon.

Top comments (0)