Domain-Driven Design in PHP

Ubiquitous Language

The ubiquitous language is the shared vocabulary between domain experts and engineers. Every class name, method, and variable in the domain layer must be drawn from it — not from framework conventions or persistence terminology.

Wrong signal
A class named OrderManager, OrderHelper, or OrderService (when "service" isn't in the domain's vocabulary) is a sign the language is leaking into implementation jargon. Name it what the domain expert calls it: Order, OrderFulfillment, Shipment.

Map discovered language directly to types. If an expert says "a subscription becomes delinquent after two consecutive failed payments," that sentence encodes a domain rule — it belongs in Subscription::markDelinquent(), not in a SubscriptionController.

Value Objects

Value objects have no identity. Two instances are equal if their values are equal. They must be immutable — any operation that changes state returns a new instance.

Implementation

src/Domain/Order/Money.php
declare(strict_types=1);

namespace App\Domain\Order;

use InvalidArgumentException;

final class Money
{
    public function __construct(
        private readonly int      $amount,   // cents
        private readonly Currency $currency,
    ) {
        if ($amount < 0) {
            throw new InvalidArgumentException('Amount cannot be negative');
        }
    }

    public function add(Money $other): self
    {
        if (!$this->currency->equals($other->currency)) {
            throw new InvalidArgumentException('Currency mismatch');
        }
        return new self($this->amount + $other->amount, $this->currency);
    }

    public function equals(Money $other): bool
    {
        return $this->amount   === $other->amount
            && $this->currency->equals($other->currency);
    }

    public function amount(): int      { return $this->amount; }
    public function currency(): Currency { return $this->currency; }
}
Good VO candidatesNotes
Moneyamount + currency; arithmetic returns new instance
EmailAddressvalidated on construction, normalised to lowercase
DateRangestart <= end invariant enforced in constructor
Percentage0–100 bounds; no identity
Coordinateslat/lng pair; equality by value
OrderStatusenum-like; prefer enum in PHP 8.1+
PHP 8.1+ enums
Backed enums (enum Status: string) are value objects by nature — use them instead of string constants or separate VO classes for finite state sets.

Entities

Entities have identity that persists through state changes. Two Order objects with the same ID are the same order, regardless of their current field values.

Identity types

Prefer domain-generated UUIDs over database-assigned integer IDs. Auto-increment IDs force a database round-trip before the entity exists in memory, which breaks aggregate consistency guarantees.

final class OrderId
{
    public function __construct(private readonly string $value)
    {
        if (!preg_match('/^[0-9a-f]{8}(-[0-9a-f]{4}){3}-[0-9a-f]{12}$/i', $value)) {
            throw new InvalidArgumentException("Invalid OrderId: {$value}");
        }
    }

    public static function generate(): self
    {
        return new self(sprintf(
            '%04x%04x-%04x-%04x-%04x-%04x%04x%04x',
            mt_rand(0, 0xffff), mt_rand(0, 0xffff),
            mt_rand(0, 0xffff),
            mt_rand(0, 0x0fff) | 0x4000,
            mt_rand(0, 0x3fff) | 0x8000,
            mt_rand(0, 0xffff), mt_rand(0, 0xffff), mt_rand(0, 0xffff),
        ));
    }

    public function value(): string { return $this->value; }
    public function equals(self $other): bool { return $this->value === $other->value; }
}

Aggregates

An aggregate is a cluster of entities and value objects with a single aggregate root. All external access goes through the root. Invariants that span multiple objects in the cluster are enforced by the root.

Rules

Reference other aggregates by ID only — never hold a direct object reference across aggregate boundaries. Hydrating a foreign aggregate on every access kills performance and couples boundaries.

src/Domain/Order/Order.php
final class Order
{
    private OrderStatus $status;

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

    /** @var DomainEvent[] */
    private array $events = [];

    public function __construct(
        private readonly OrderId    $id,
        private readonly CustomerId $customerId,  // foreign ID, not object
        private readonly Money      $shippingFee,
    ) {
        $this->status = OrderStatus::Draft;
    }

    public function addLine(ProductId $productId, int $qty, Money $unitPrice): void
    {
        $this->assertStatus(OrderStatus::Draft);

        if (count($this->lines) >= 50) {
            throw new OrderLineLimit('Max 50 lines per order');
        }

        $this->lines[] = new OrderLine($productId, $qty, $unitPrice);
    }

    public function place(): void
    {
        $this->assertStatus(OrderStatus::Draft);

        if ($this->lines === []) {
            throw new EmptyOrderException('Cannot place an empty order');
        }

        $this->status = OrderStatus::Placed;
        $this->events[] = new OrderPlaced($this->id, $this->customerId, $this->total());
    }

    public function total(): Money
    {
        return array_reduce(
            $this->lines,
            fn(Money $carry, OrderLine $line) => $carry->add($line->subtotal()),
            $this->shippingFee,
        );
    }

    public function pullEvents(): array
    {
        $events       = $this->events;
        $this->events = [];
        return $events;
    }

    private function assertStatus(OrderStatus $expected): void
    {
        if ($this->status !== $expected) {
            throw new InvalidOrderOperation(
                "Operation requires status {$expected->name}, got {$this->status->name}"
            );
        }
    }
}
Aggregate size
Keep aggregates small. An aggregate containing hundreds of child entities is usually a sign that some of those children should be their own aggregates, referenced by ID. Larger aggregates create longer transaction locks and more merge conflicts.

Invariant checklist per aggregate boundary

QuestionIf yes
Can this entity exist without the root?It may be its own aggregate
Does the root enforce invariants across these entities?Keep them in the same aggregate
Do they get saved together in one transaction?Good signal they belong together
Is the aggregate ever loaded only to read this child?Consider splitting

Repositories

A repository provides collection-like access to aggregates. It abstracts persistence — the domain layer defines the interface; infrastructure implements it.

src/Domain/Order/OrderRepository.php
interface OrderRepository
{
    public function get(OrderId $id): Order;          // throws if not found
    public function find(OrderId $id): ?Order;        // null if not found
    public function save(Order $order): void;
    public function delete(Order $order): void;

    /** @return Order[] */
    public function findByCustomer(CustomerId $customerId): array;
}
src/Infrastructure/Persistence/DoctrineOrderRepository.php
final class DoctrineOrderRepository implements OrderRepository
{
    public function __construct(private readonly EntityManagerInterface $em) {}

    public function get(OrderId $id): Order
    {
        return $this->find($id)
            ?? throw new OrderNotFound($id);
    }

    public function find(OrderId $id): ?Order
    {
        return $this->em->find(Order::class, $id->value());
    }

    public function save(Order $order): void
    {
        $this->em->persist($order);
        $this->em->flush();
    }

    public function delete(Order $order): void
    {
        $this->em->remove($order);
        $this->em->flush();
    }

    public function findByCustomer(CustomerId $customerId): array
    {
        return $this->em
            ->createQuery('SELECT o FROM Order o WHERE o.customerId = :id')
            ->setParameter('id', $customerId->value())
            ->getResult();
    }
}
Repository vs query service
Repositories are for aggregate retrieval by identity or simple criteria. For complex read models (dashboards, reports), use a dedicated query service or read model that issues SQL directly — don't torture your domain model into a DTO factory.

In-memory implementation for tests

final class InMemoryOrderRepository implements OrderRepository
{
    /** @var array<string, Order> */
    private array $store = [];

    public function get(OrderId $id): Order
    {
        return $this->store[$id->value()]
            ?? throw new OrderNotFound($id);
    }

    public function find(OrderId $id): ?Order
    {
        return $this->store[$id->value()] ?? null;
    }

    public function save(Order $order): void
    {
        $this->store[$order->id()->value()] = $order;
    }

    public function delete(Order $order): void
    {
        unset($this->store[$order->id()->value()]);
    }

    public function findByCustomer(CustomerId $customerId): array
    {
        return array_values(array_filter(
            $this->store,
            fn(Order $o) => $o->customerId()->equals($customerId),
        ));
    }
}

Domain Events

Domain events record something that happened in the domain. They are named in the past tense, are immutable, and carry all data needed by handlers.

src/Domain/Shared/DomainEvent.php
interface DomainEvent
{
    public function occurredAt(): DateTimeImmutable;
}
src/Domain/Order/OrderPlaced.php
final readonly class OrderPlaced implements DomainEvent
{
    public DateTimeImmutable $occurredAt;

    public function __construct(
        public readonly OrderId    $orderId,
        public readonly CustomerId $customerId,
        public readonly Money      $total,
    ) {
        $this->occurredAt = new DateTimeImmutable();
    }

    public function occurredAt(): DateTimeImmutable
    {
        return $this->occurredAt;
    }
}

Dispatch strategy

StrategyGuaranteesWhen to use
Collect + dispatch after saveEvents fire only if save succeedsDefault — most use cases
Transactional outboxAt-least-once delivery across process boundariesAsync handlers, microservices
Synchronous in-transactionHandlers run in same DB transactionRarely justified; couples bounded contexts

The pullEvents() pattern (clearing the event queue after reading) is the standard collect-and-dispatch approach. The application layer calls it after $repository->save($order) and dispatches to an event bus.

Domain Services

Domain services contain domain logic that doesn't naturally belong to a single entity or value object, typically operations that require multiple aggregates or external domain interfaces.

Overuse warning
Most logic that ends up in a domain service actually belongs on an aggregate or value object. If a service is your first instinct, ask whether the method can live on one of its inputs.
src/Domain/Pricing/PriceCalculator.php
// Needs access to multiple aggregates + a domain-level discount policy
final class PriceCalculator
{
    public function __construct(
        private readonly DiscountRepository $discounts,
    ) {}

    public function calculate(Order $order, Customer $customer): Money
    {
        $base     = $order->total();
        $discount = $this->discounts->findFor($customer->tier());

        return $discount !== null
            ? $discount->apply($base)
            : $base;
    }
}

Application Layer

The application layer orchestrates use cases. It holds no domain logic — it coordinates domain objects, commits transactions, and dispatches events. The standard unit is a command + handler pair.

src/Application/Order/PlaceOrderCommand.php
final readonly class PlaceOrderCommand
{
    public function __construct(
        public readonly string $orderId,
        public readonly string $customerId,
        public readonly array  $lines,     // [{productId, qty, unitPrice}]
        public readonly int    $shippingFee,
        public readonly string $currency,
    ) {}
}
src/Application/Order/PlaceOrderHandler.php
final class PlaceOrderHandler
{
    public function __construct(
        private readonly OrderRepository  $orders,
        private readonly EventBusInterface $events,
    ) {}

    public function __invoke(PlaceOrderCommand $cmd): void
    {
        $currency = new Currency($cmd->currency);
        $order    = new Order(
            new OrderId($cmd->orderId),
            new CustomerId($cmd->customerId),
            new Money($cmd->shippingFee, $currency),
        );

        foreach ($cmd->lines as $line) {
            $order->addLine(
                new ProductId($line['productId']),
                $line['qty'],
                new Money($line['unitPrice'], $currency),
            );
        }

        $order->place();
        $this->orders->save($order);

        foreach ($order->pullEvents() as $event) {
            $this->events->dispatch($event);
        }
    }
}
Layer dependency rule
Domain → no dependencies. Application → Domain only. Infrastructure → Domain + Application. Presentation → Application. Never let Domain depend on Infrastructure.

Bounded Contexts

A bounded context is an explicit boundary within which a domain model applies. The same word can mean different things in different contexts — "customer" in Billing may carry invoice data that Order context doesn't need or own.

Context map patterns

PatternDirectionUse when
Shared KernelBidirectionalTwo teams share a small, stable subset of the model; changes require joint agreement
Customer/SupplierUpstream/downstreamDownstream team's needs formally influence upstream's backlog
ConformistDownstream follows upstreamDownstream accepts upstream model as-is (e.g. third-party API)
Anti-Corruption LayerDownstream translatesDownstream needs to isolate from upstream's model — see below
Open Host ServiceUpstream publishesUpstream exposes a stable protocol for multiple consumers
Published LanguageBoth sidesWell-documented shared format (e.g. domain events on a message bus)

In a PHP monolith, bounded contexts are enforced with namespaces and automated architecture tests (Deptrac, PHPArkitect). Namespace violations are caught in CI rather than at runtime.

# deptrac.yaml — prevent Billing from importing Order internals
layers:
  - name: Billing
    collectors:
      - type: namespace
        regex: ^App\\Domain\\Billing
  - name: Order
    collectors:
      - type: namespace
        regex: ^App\\Domain\\Order
ruleset:
  Billing:
    - Order     # disallowed — Billing must go via ACL or events

Anti-Corruption Layer

The ACL translates between a foreign context (or external service) and your domain model. It prevents external concepts from leaking into your domain.

src/Infrastructure/Legacy/LegacyOrderAcl.php
/**
 * Translates the legacy ERP's order format into domain objects.
 * The domain never sees LegacyOrder.
 */
final class LegacyOrderAcl
{
    public function __construct(
        private readonly LegacyErpClient $erp
    ) {}

    public function findOrder(OrderId $id): ?Order
    {
        $raw = $this->erp->getOrder($id->value());

        if ($raw === null) {
            return null;
        }

        return $this->translate($raw);
    }

    private function translate(array $raw): Order
    {
        $currency = new Currency($raw['curr_code']);
        $order    = new Order(
            new OrderId($raw['ord_uuid']),
            new CustomerId($raw['cust_ref']),
            new Money((int) ($raw['ship_fee'] * 100), $currency),
        );

        foreach ($raw['items'] as $item) {
            $order->addLine(
                new ProductId($item['sku']),
                (int) $item['qty'],
                new Money((int) ($item['unit_price'] * 100), $currency),
            );
        }

        return $order;
    }
}

Directory Layout

src/
├── Domain/
│   ├── Order/
│   │   ├── Order.php             # aggregate root
│   │   ├── OrderId.php           # VO identity
│   │   ├── OrderLine.php         # child entity
│   │   ├── OrderStatus.php       # backed enum
│   │   ├── OrderPlaced.php       # domain event
│   │   ├── OrderRepository.php   # interface
│   │   └── Exceptions/
│   ├── Pricing/
│   │   ├── PriceCalculator.php   # domain service
│   │   └── DiscountRepository.php
│   └── Shared/
│       ├── DomainEvent.php
│       ├── Money.php
│       └── Currency.php
├── Application/
│   └── Order/
│       ├── PlaceOrderCommand.php
│       └── PlaceOrderHandler.php
└── Infrastructure/
    ├── Persistence/
    │   ├── DoctrineOrderRepository.php
    │   └── InMemoryOrderRepository.php
    └── Legacy/
        └── LegacyOrderAcl.php
Shared kernel scope
Domain/Shared must stay small — only types that genuinely cross every context (Money, DomainEvent, aggregate base classes). If it grows, extract into per-context shared types and be explicit about which contexts share which types.

Common Mistakes

MistakeConsequenceFix
Anemic domain modelAll logic lives in services; entities are bags of getters/settersMove behaviour to entities; entities should enforce their own invariants
Fat aggregateLong DB locks, merge conflicts, slow hydrationSplit by transaction boundary; cross-boundary ops use events
ORM entity = domain entityPersistence schema bleeds into domain (nullable FKs, surrogate IDs)Separate ORM mapping from domain objects, or use Doctrine embeddables/mappings carefully
Repository as query builderDozens of findByX methods; business queries in infrastructureAdd a dedicated read model / query service; keep repository thin
Cross-aggregate object refsCoupled loading, circular hydration, broken aggregate boundariesReference by ID only; load separately in application layer
Domain events fired pre-saveHandlers see changes that may not persistDispatch events after successful save(), not before
Using ints/strings for IDsWrong ID type passed silently; no domain validationTyped ID value objects (OrderId, CustomerId)
Application logic in domainDomain depends on HTTP request, session, or infrastructureDomain is pure PHP — no framework interfaces, no superglobals