DEV Community

Cover image for Aggregate Roots: Why Your Entity Needs a Private Constructor in PHP
Gabriel Anhaia
Gabriel Anhaia

Posted on

Aggregate Roots: Why Your Entity Needs a Private Constructor in PHP


You open a pull request. Someone on the team is building drafts. They add a new way to create an Order from a saved cart row. The diff is two lines:

$order = new Order();
$order->customerId = $cart->customerId;
Enter fullscreen mode Exit fullscreen mode

No items. No total. No created-at. The fluent setter style spreads quickly, because the constructor is public and the fields are public-ish. A week later, a queue worker pops a job, runs new Order(), fills three fields, persists. Half a year later, the analytics team asks why a non-trivial slice of orders have a total_cents of zero and no line items. You spend a Friday afternoon writing a SQL migration to backfill rows that should never have existed.

The class never enforced what an Order is. The constructor accepted any combination of nothing. PHP let it happen.

This is the problem a private constructor plus named factories fixes. new Order(...) is a leak. Order::place(...) is a contract. The invariants live in one place, and the type system finally does its job.

The leaky version

Here is the shape most PHP codebases start with. Public constructor, public properties, framework hydration. Eloquent and Doctrine both encourage variations of it.

<?php

declare(strict_types=1);

final class Order
{
    public string $id;
    public string $customerId;
    /** @var Item[] */
    public array $items = [];
    public int $totalCents = 0;
    public ?\DateTimeImmutable $placedAt = null;
    public string $status = 'draft';
}
Enter fullscreen mode Exit fullscreen mode

Every field is settable from anywhere. Every combination is reachable. A test, a controller, a migration script, a console command: anyone can build an Order that violates the rules nobody wrote down. The rules live in tribal memory: "an order needs at least one item before it's placed", "the total is the sum of items times quantity", "placedAt is set when status flips to placed".

You read those rules in code review. You don't read them in the type. The compiler doesn't either.

The first instinct is to add a fat constructor:

public function __construct(
    string $id,
    string $customerId,
    array $items,
    int $totalCents,
    \DateTimeImmutable $placedAt,
) { /* ... */ }
Enter fullscreen mode Exit fullscreen mode

That helps a little. It doesn't help enough. The constructor still has one name (__construct), one signature, and every caller has to know whether they are placing a fresh order, rehydrating one from the database, or replaying one from an event stream. Those are three different operations with three different invariants, and the type signature gives the caller no way to tell them apart.

Three callers of new Order all pretending to mean the same thing

The contract version

Move the constructor to private and expose named static factories. Each factory is a verb in the domain. Each factory enforces the invariants for its specific entry point.

<?php

declare(strict_types=1);

namespace App\Domain\Order;

use App\Domain\Customer\CustomerId;
use App\Domain\Money\Money;
use App\Domain\Order\Exception\EmptyOrderException;

final class Order
{
    /**
     * @param list<LineItem> $items
     */
    private function __construct(
        private readonly OrderId $id,
        private readonly CustomerId $customerId,
        private array $items,
        private Money $total,
        private OrderStatus $status,
        private readonly \DateTimeImmutable $placedAt,
    ) {
    }

    /**
     * Place a brand-new order. The only way a fresh
     * Order can exist in the domain.
     *
     * @param non-empty-list<LineItem> $items
     */
    public static function place(
        OrderId $id,
        CustomerId $customerId,
        array $items,
        \DateTimeImmutable $placedAt,
    ): self {
        if ($items === []) {
            throw new EmptyOrderException(
                'Cannot place an order with zero items.'
            );
        }

        $total = Money::sum(
            array_map(static fn (LineItem $i) => $i->subtotal(), $items)
        );

        return new self(
            id: $id,
            customerId: $customerId,
            items: array_values($items),
            total: $total,
            status: OrderStatus::Placed,
            placedAt: $placedAt,
        );
    }

    /**
     * Rebuild an order from persistence. No invariants
     * re-checked: persistence is trusted, and any check
     * here would mask bugs in `place`.
     *
     * @internal Only the repository may call this.
     * @param list<LineItem> $items
     */
    public static function fromPersistence(
        OrderId $id,
        CustomerId $customerId,
        array $items,
        Money $total,
        OrderStatus $status,
        \DateTimeImmutable $placedAt,
    ): self {
        return new self(
            id: $id,
            customerId: $customerId,
            items: $items,
            total: $total,
            status: $status,
            placedAt: $placedAt,
        );
    }
}
Enter fullscreen mode Exit fullscreen mode

The constructor went private; no caller anywhere in the application can write new Order(...). The only ways an Order materialises are Order::place(...) and Order::fromPersistence(...). Each one is named, each one tells you what the order means at that moment.

The invariants stayed where they were. Empty-items check, total computation, status assignment: they live in place and nowhere else. fromPersistence does not re-run them, because the database already holds an order that was once valid. Re-running invariants on rehydration hides the broken writer that produced the row.

A team I worked with, some time back, had three different services running new Order() with whatever fields the local code happened to need. After the refactor, the IDE listed two ways to create an Order across the codebase. New engineers stopped guessing. Code review stopped catching the same mistake every sprint.

Why "named factories" beats "fat constructor"

A public constructor is a wildcard. Once it exists, every caller decides what it means.

Named factories give every entry point a sentence:

  • Order::place(...) — a customer placed an order. Invariants apply.
  • Order::fromPersistence(...) — the database returned a row. Trust it.
  • Order::fromEventStream(array $events) — we are rebuilding from an event log. Replay each event.
  • Order::draft(CustomerId $customerId) — a draft was started. Different rules, different state.

Each one is a contract. You read the call site and you know what kind of Order you are looking at; the implementation tells you which invariants matter at which entry point.

This is the same idea behind DateTimeImmutable::createFromFormat, Uuid::fromString / Uuid::v7, Money::fromCents / Money::fromString. The standard library and most value-object libraries converged on it. Apply the pattern to your domain entities and the call site reads the same way.

Behaviour methods, not setters

The other half of the aggregate-root discipline is on the mutation side. Once an Order exists, the only way it changes is through methods that mean something in the domain.

public function addItem(LineItem $item): void
{
    if ($this->status !== OrderStatus::Draft) {
        throw new OrderAlreadyPlacedException(
            'Cannot add items to a placed order.'
        );
    }

    $this->items[] = $item;
    $this->total = Money::sum(
        array_map(static fn (LineItem $i) => $i->subtotal(), $this->items)
    );
}

public function cancel(\DateTimeImmutable $cancelledAt, string $reason): void
{
    if ($this->status === OrderStatus::Cancelled) {
        return;
    }
    if ($this->status === OrderStatus::Shipped) {
        throw new OrderAlreadyShippedException(
            'Cannot cancel a shipped order.'
        );
    }

    $this->status = OrderStatus::Cancelled;
    $this->cancelledAt = $cancelledAt;
    $this->cancellationReason = $reason;
}
Enter fullscreen mode Exit fullscreen mode

addItem and cancel are verbs from the business. The status check lives inside the method. A controller that wants to cancel an order calls cancel. It does not call setStatus('cancelled'), because setStatus does not exist.

When a product manager says "we want to allow cancellation up to 30 minutes after placement", the change is one method in one file. When a Cancel button somewhere in the codebase forgets the new rule, the type system reminds it: Order::cancel is the only path.

One door in, named exits out — the aggregate boundary

How Doctrine hydrates a private constructor

The most common objection: "a private constructor breaks Doctrine." It does not. Doctrine's default hydrator does not use the constructor.

The hydration path uses ReflectionClass::newInstanceWithoutConstructor(). The ORM creates an empty instance, then sets each mapped property via reflection. Visibility does not matter; the constructor (public, private, or absent) is never called.

You can confirm this in your own codebase. Drop a throw new \LogicException('constructor called') into the constructor body, run a query that hydrates the entity, and watch the test pass. The constructor is dead code from Doctrine's perspective.

So the mapping looks the way it always did:

<?php

declare(strict_types=1);

namespace App\Domain\Order;

use Doctrine\ORM\Mapping as ORM;

#[ORM\Entity(repositoryClass: OrderRepository::class)]
#[ORM\Table(name: 'orders')]
final class Order
{
    #[ORM\Id]
    #[ORM\Column(type: 'order_id')]
    private OrderId $id;

    #[ORM\Column(type: 'customer_id')]
    private CustomerId $customerId;

    /** @var list<LineItem> */
    #[ORM\OneToMany(
        mappedBy: 'order',
        targetEntity: LineItem::class,
        cascade: ['persist'],
        orphanRemoval: true,
    )]
    private array $items;

    #[ORM\Embedded(class: Money::class)]
    private Money $total;

    #[ORM\Column(enumType: OrderStatus::class)]
    private OrderStatus $status;

    #[ORM\Column(name: 'placed_at')]
    private \DateTimeImmutable $placedAt;
}
Enter fullscreen mode Exit fullscreen mode

The repository's find() and DQL queries keep working. The properties are private and the entity stays a proper aggregate root. Hydration goes through reflection. Behaviour and construction go through the methods and factories you named.

fromPersistence becomes a documented seam rather than the actual hydration path. It is the intended construction route for code paths that aren't Doctrine: event sourcing, snapshot replay, raw SQL adapters, tests. If you stop using Doctrine and switch to manual SQL, fromPersistence is already the right shape, and the migration is a search-and-replace rather than a redesign.

Eloquent users have a tighter fight. Model::__construct is public and the framework wants it that way. The honest answer is that Eloquent models are not aggregate roots; they are active-record table gateways. The pattern in this post applies to a pure domain class that sits next to the Eloquent model. Hydrate the Eloquent model from the database, then map it to the domain Order via a factory. That is the price of using a framework that owns your entity's identity.

When the discipline pays back

It pays back the first time someone joins the team and asks "how do I create an order in a test?" The answer is Order::place(...). That is also the answer to "how does production create an order?" One way in. Tests look like production.

The second payback shows up when you add a state (Returned, PartiallyShipped, OnHold) and you need to enumerate every code path that could land in the new state. The IDE gives you a finite list: every call site of every named factory and every behaviour method. No setStatus($whatever) to grep for.

Changing the invariant is cheaper too. Empty orders are now allowed in a B2B flow but still forbidden in B2C. You add Order::placeB2B(...) as a new factory, you keep Order::place(...) strict, and the rest of the system does not change. Each entry point gets its own contract; existing callers keep their guarantees.

Bugs become smaller too. Production has an Order with no items. You know, without running anything, that the only function on Earth that could have created it is fromPersistence. The bug is in your migration, your replication, your seed script, or a hand-written SQL INSERT. It is not in the domain. That narrows a six-hour investigation to a fifteen-minute one.

What to keep, what to skip

Keep, for every aggregate root:

  • private function __construct(...) — the constructor is an implementation detail of the factories.
  • One named static factory per meaningful way the entity comes into being — usually two or three.
  • Behaviour methods named after domain verbs — place, cancel, ship, refund. No setX.
  • Invariant checks inside the factory or behaviour method that introduces them, never duplicated on rehydration.

Skip, until you actually need them:

  • A base class. PHP traits and abstract AggregateRoot classes earn their keep when you are tracking domain events. Until then, they add a layer that hides the discipline.
  • A repository interface inside the entity. Keep the repository in its own namespace. The entity does not need to know how it is stored.
  • Reflection guards (if (debug_backtrace()...) to enforce "only the repository may call fromPersistence". The @internal doc tag plus a PHPStan rule covers it for free.

The whole pattern is two language features (private visibility, static methods) and one habit (name the verbs). Modern PHP makes it pleasant: readonly properties, named arguments, native enums, first-class callable syntax. The discipline does not depend on those features; they just make the code shorter.

The next time you reach for new Order(), ask which kind of order you mean. If you can answer that question with a sentence, that sentence is the name of a factory.


If this was useful

The aggregate-root discipline is one of the patterns I unpack at length in Decoupled PHP — alongside ports, adapters, use cases, and the migration playbook for Laravel and Symfony apps that have been around long enough to feel like sediment. The goal of the book is the same as this post: an application where the framework is an adapter, not the protagonist, and where the domain still makes sense after the third framework upgrade.

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)