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.
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 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.
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 candidates | Notes |
|---|---|
| Money | amount + currency; arithmetic returns new instance |
| EmailAddress | validated on construction, normalised to lowercase |
| DateRange | start <= end invariant enforced in constructor |
| Percentage | 0–100 bounds; no identity |
| Coordinates | lat/lng pair; equality by value |
| OrderStatus | enum-like; prefer enum in PHP 8.1+ |
enum Status: string) are value objects by nature — use them instead of string constants or separate VO classes for finite state sets.
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.
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; }
}
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.
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.
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}"
);
}
}
}
| Question | If 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 |
A repository provides collection-like access to aggregates. It abstracts persistence — the domain layer defines the interface; infrastructure implements it.
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;
}
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();
}
}
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 record something that happened in the domain. They are named in the past tense, are immutable, and carry all data needed by handlers.
interface DomainEvent
{
public function occurredAt(): DateTimeImmutable;
}
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;
}
}
| Strategy | Guarantees | When to use |
|---|---|---|
| Collect + dispatch after save | Events fire only if save succeeds | Default — most use cases |
| Transactional outbox | At-least-once delivery across process boundaries | Async handlers, microservices |
| Synchronous in-transaction | Handlers run in same DB transaction | Rarely 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 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.
// 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;
}
}
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.
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,
) {}
}
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);
}
}
}
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.
| Pattern | Direction | Use when |
|---|---|---|
| Shared Kernel | Bidirectional | Two teams share a small, stable subset of the model; changes require joint agreement |
| Customer/Supplier | Upstream/downstream | Downstream team's needs formally influence upstream's backlog |
| Conformist | Downstream follows upstream | Downstream accepts upstream model as-is (e.g. third-party API) |
| Anti-Corruption Layer | Downstream translates | Downstream needs to isolate from upstream's model — see below |
| Open Host Service | Upstream publishes | Upstream exposes a stable protocol for multiple consumers |
| Published Language | Both sides | Well-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
The ACL translates between a foreign context (or external service) and your domain model. It prevents external concepts from leaking into your domain.
/**
* 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;
}
}
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
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.
| Mistake | Consequence | Fix |
|---|---|---|
| Anemic domain model | All logic lives in services; entities are bags of getters/setters | Move behaviour to entities; entities should enforce their own invariants |
| Fat aggregate | Long DB locks, merge conflicts, slow hydration | Split by transaction boundary; cross-boundary ops use events |
| ORM entity = domain entity | Persistence schema bleeds into domain (nullable FKs, surrogate IDs) | Separate ORM mapping from domain objects, or use Doctrine embeddables/mappings carefully |
| Repository as query builder | Dozens of findByX methods; business queries in infrastructure | Add a dedicated read model / query service; keep repository thin |
| Cross-aggregate object refs | Coupled loading, circular hydration, broken aggregate boundaries | Reference by ID only; load separately in application layer |
| Domain events fired pre-save | Handlers see changes that may not persist | Dispatch events after successful save(), not before |
| Using ints/strings for IDs | Wrong ID type passed silently; no domain validation | Typed ID value objects (OrderId, CustomerId) |
| Application logic in domain | Domain depends on HTTP request, session, or infrastructure | Domain is pure PHP — no framework interfaces, no superglobals |