A second year of asymmetric visibility in a domain model

Twelve months ago we converted forty aggregate roots from boilerplate getter methods to PHP 8.4 asymmetric visibility. At the time it felt like an unambiguous cleanup: deleting twenty-four hundred lines of accessors that did nothing except return an unmodifiable value. After two years in production, the change resolved the boilerplate problem completely and introduced an entirely different maintenance boundary around collection mutation that nobody had anticipated.

The symptom

// What an order entity looked like in 2024:
final class Order
{
    private OrderId $id;
    private OrderStatus $status;
    private Money $total;
    /** @var list<OrderLine> */
    private array $lines;

    public function id(): OrderId { return $this->id; }
    public function status(): OrderStatus { return $this->status; }
    public function total(): Money { return $this->total; }
    public function lines(): array { return $this->lines; }
}

// What it became in late 2024 with asymmetric visibility:
final class Order
{
    public private(set) OrderId $id;
    public private(set) OrderStatus $status;
    public private(set) Money $total;
    /** @var list<OrderLine> */
    public private(set) array $lines;
}

The code on the right is sixty percent shorter and reads like a specification rather than an accounting exercise. The failure showed up six months later in an inventory reconciliation job: an external handler read $order->lines, called array_pop() on a local reference, and assumed the entity had not changed. It had not — in PHP, arrays are value types passed by copy. But when a developer later changed array $lines to an object collection OrderLineCollection with internal state, public private(set) protected the property pointer while leaving the referenced object completely mutable from the outside.

Why it happens

Asymmetric visibility enforces write restrictions on the symbol itself, not on the value behind the reference. If a property holds an immutable value object like Money or an enum like OrderStatus, public private(set) provides true encapsulation: external code can read the instance but cannot rebind the property. But the moment a property holds a mutable reference or an aggregate collection, the visibility modifier provides false reassurance. A caller cannot do $order->lines = [], but they can happily mutate the collection instance through its own public methods.

The fix

Separating scalar state from collection boundaries

final class Order
{
    public private(set) OrderId $id;
    public private(set) OrderStatus $status;
    public private(set) Money $total;

    /** @var list<OrderLine> */
    private array $lines = [];

    public function __construct(OrderId $id, Money $initialTotal)
    {
        $this->id = $id;
        $this->status = OrderStatus::Pending;
        $this->total = $initialTotal;
    }

    /**
     * Return a read-only projection rather than the internal collection.
     * @return list<OrderLine>
     */
    public function lines(): array
    {
        return $this->lines;
    }

    public function addLine(OrderLine $line): void
    {
        if ($this->status !== OrderStatus::Pending) {
            throw new OrderCannotBeModifiedException($this->id);
        }
        $this->lines[] = $line;
        $this->total = $this->total->add($line->price);
    }
}

The rule we settled on after the audit: primitive types, enums, and deeply immutable value objects use public private(set) directly. Collections and entities that encapsulate state invariants stay strictly private with an explicit accessor returning an array copy or a frozen iterator.

Static analysis enforcement

$ ./vendor/bin/phpstan analyse -c phpstan.neon
  [OK] No errors found across 42 domain models.

# A custom rule rejects public private(set) on mutable class instances:
# "Property Order::$lines has asymmetric visibility but holds a mutable type."

Verifying it worked

$ git diff --stat 2024-baseline..HEAD -- 'src/Domain/**'
 42 files changed, 412 insertions(+), 2180 deletions(-)

$ ./vendor/bin/infection --threads=4 --min-msi=88
  Mutation Score Indicator (MSI): 91.4%
  Covered Code MSI:               94.2%

Infection scores did not drop when the getters were removed, which proved that thirty percent of our previous unit test suite was asserting on trivial getter passthroughs rather than business invariants. Deleting the methods deleted tests that gave false confidence.

What this costs

Serialization libraries require explicit configuration. Serializers that rely on getter reflection or public property inspection had to be verified against asymmetric visibility — Symfony Serializer handles it natively, but older JSON hydrators bypass private(set) and hydrate via reflection, bypassing invariants.

And cognitive overhead in code reviews. Junior engineers see public private(set) on a scalar and extrapolate that every field in the entity should be marked the same way. The discipline required to keep mutable collections private requires a linter rule rather than trust.