Index

Entity, Value Object, Aggregate, Model, DTO: Name the Role

An Order can be an Eloquent model, a domain entity, an aggregate root, an API resource, and a row in a database. Those statements describe different roles. Treating them as synonyms makes design conversations sound precise while each person is discussing a different boundary.

DDD’s modelling vocabulary becomes useful through concrete questions:

  • Does this thing remain the same when its attributes change?
  • Is equality based on identity or on all constituent values?
  • Which rules must be consistent before one transaction commits?
  • Is this shape for domain behavior, persistence, or transfer?

The labels follow those answers.

An entity keeps identity through change

Two orders with identical customer, lines, and total are still two orders. One order can change from draft to placed without becoming a new order. That continuity makes it an entity.

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

    private function __construct(
        public readonly OrderId $id,
        private readonly CustomerId $customerId,
        private OrderStatus $status,
    ) {}

    public static function draft(
        OrderId $id,
        CustomerId $customerId,
    ): self {
        return new self($id, $customerId, OrderStatus::Draft);
    }

    public function place(): void
    {
        if ($this->lines === []) {
            throw OrderCannotBePlaced::withoutLines($this->id);
        }

        $this->status = OrderStatus::Placed;
    }
}

The identifier is not merely a primary-key implementation detail. It answers whether two in-memory objects refer to the same conceptual order.

The private constructor matters for a different reason: callers cannot create a placed order with no lines. draft() establishes the only valid state for a new order in this example. Identity classifies the object; controlled creation protects its initial state.

Not every Eloquent model is a useful domain entity. A DailySalesReport model may represent a replaceable projection row with no independent identity in the business. Conversely, a domain entity can be persisted without extending Eloquent. Framework inheritance and domain classification answer different questions.

A value object is equal by value

Money is not “an entity without an ID.” Two amounts of EUR 10 are interchangeable when amount and currency match:

final readonly class Money
{
    public function __construct(
        public int $minor,
        public Currency $currency,
    ) {
        if ($minor < 0) {
            throw new InvalidArgumentException('Money cannot be negative.');
        }
    }

    public function add(self $other): self
    {
        if ($this->currency !== $other->currency) {
            throw CurrencyMismatch::between($this, $other);
        }

        return new self($this->minor + $other->minor, $this->currency);
    }

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

Value objects collect validation, equality, and operations that primitive strings and integers cannot express. Immutability makes them easier to share, but immutability alone does not define the role: an immutable Order still has identity.

Context decides the classification. A postal address may be a value inside checkout and an identified, historically tracked entity inside a property registry. The real world does not assign one universal object type.

Laravel can persist value objects through custom casts. That is mapping code, not the source of the value’s invariants. A Money that is valid only after Eloquent casts it is unsafe everywhere else it can be constructed.

The non-negative rule above belongs to this checkout example, where Money represents a price. A ledger may need signed amounts. Value equality remains the same while the valid range changes with the role.

An aggregate defines a consistency boundary

An aggregate is a cluster of domain objects changed as one unit, with an aggregate root controlling access. Fowler’s DDD Aggregate description uses order and line items as the canonical shape.

For this order, the rule “a placed order has at least one line” must hold when the transaction commits. Callers add lines through the root:

final class Order
{
    /** @var array<string, OrderLine> */
    private array $lines = [];

    public function addProduct(
        ProductId $productId,
        Quantity $quantity,
        Money $unitPrice,
    ): void {
        $line = OrderLine::for($productId, $quantity, $unitPrice);
        $this->lines[$line->id->value] = $line;
    }
}

External code should not load an OrderLine and mutate its quantity without the order. That would bypass order-level rules such as status, currency, or maximum quantity.

The aggregate is not “everything displayed on the order page.” Customer, payment, shipment, and support-case data may appear together while belonging to separate consistency boundaries. Loading all of them for every command can expand lock scope and make transactions harder to reason about.

An aggregate boundary earns its size through invariants. If a rule can tolerate delay, coordinate it across aggregates with an application process. If it must hold at commit, either keep the required state inside one boundary or use a database constraint that can enforce it atomically.

The boundary says what must change consistently; it does not stop two workers from saving different versions of the same aggregate. The repository must enforce a concurrency choice, such as a row lock or an optimistic version check. A repository’s save contract carries that persistence decision without turning it into another aggregate type.

Aggregate root is a role an entity plays

Order is both an entity and the root of the order aggregate. OrderLine may also have identity within that aggregate, but callers address it through the order:

$order->changeQuantity($lineId, Quantity::of(3));

This protects the boundary. It does not require every child to be a value object, nor does it mean roots should contain every operation related to an order. Sending email and calling a carrier remain application or infrastructure responsibilities.

Repositories normally load and save roots, not arbitrary internal children:

interface Orders
{
    public function get(OrderId $id): Order;
    public function save(Order $order): void;
}

The plural domain name communicates a collection of aggregates. It does not need generic findBy, where, or pagination methods for every read screen.

“Model” is too broad to settle a design argument

A model is a representation built for a purpose. Several models can describe the same order:

Order aggregate       protects placement and cancellation rules
OrderDetails          serves the customer-facing page
RevenueByCountry      serves finance analysis
Eloquent OrderRecord  maps an orders table

Calling all four “the order model” hides their different owners and change reasons. Use a more specific term when the distinction matters.

An Eloquent class can combine persistence mapping and domain behavior in a small application. That is a valid trade-off when framework concerns do not distort the model. Separating a pure domain aggregate from an Eloquent record adds mapping work, so do it when independent testing, persistence complexity, or domain rules justify the cost—not to satisfy a folder diagram.

Creation and reconstitution are different entry points

Order::draft() answers whether a new order may be created under today’s rules. Loading an existing order answers a different question: which accepted state was stored earlier?

A mapper should make that distinction visible:

$order = Order::reconstitute(
    id: OrderId::fromString($record->id),
    customerId: CustomerId::fromString($record->customer_id),
    status: OrderStatus::from($record->status),
    lines: OrderLineMapper::fromRecords($record->lines),
    version: Version::fromInt($record->version),
);

Reconstitution should not run a creation workflow, emit “order created” again, or silently repair malformed storage. It applies reconstruction rules instead of rerunning rules for a new decision, and it must still reject structurally impossible state. The mapping owns column translation, child reconstruction, null semantics, and the persisted version; the aggregate still owns what its state means.

A DTO carries data across a boundary

A Data Transfer Object describes a message shape. It does not own an aggregate’s invariants:

final readonly class PlaceOrderData
{
    /** @param list<PlaceOrderLineData> $lines */
    public function __construct(
        public string $customerId,
        public array $lines,
    ) {}
}

The DTO can reflect input accepted by a Laravel Form Request. The application layer translates it into domain types:

$order = Order::draft(
    OrderId::new(),
    CustomerId::fromString($data->customerId),
);

foreach ($data->lines as $line) {
    $order->addProduct(
        ProductId::fromString($line->productId),
        Quantity::of($line->quantity),
        Money::ofMinor($line->unitPriceMinor, Currency::EUR),
    );
}

$order->place();

Validation such as “quantity is an integer greater than zero” can happen at the input boundary for good errors. The domain type must still reject an invalid quantity because commands can arrive from queues, CLI tools, and tests without that HTTP validator.

The responsibilities are deliberately layered:

  • the Form Request checks HTTP shape and request-level authorization;
  • the DTO carries the accepted message without HTTP behavior;
  • Quantity and Money reject invalid local values; and
  • Order::place() decides whether the transition is valid for this order.

Passing one layer does not prove the next. A syntactically valid request can still ask to place an empty order or change an order that is already cancelled.

Returning Eloquent models directly as DTOs couples the external contract to columns, casts, hidden attributes, and relationships. A dedicated response DTO or API Resource is worthwhile when that contract must evolve separately.

Classify from behavior, not suffixes

Use the terms as tests:

  • If identity survives attribute changes, it is an entity in this context.
  • If equality comes entirely from constituent values, it is a value object.
  • If a cluster must preserve rules in one consistency boundary, it is an aggregate.
  • If an entity controls that boundary, it is the aggregate root.
  • If a shape exists to cross a boundary, it is a DTO.
  • If someone says only “model,” ask which purpose it models.

These roles can overlap without becoming interchangeable. Order can be an entity and aggregate root. Money can be embedded through an Eloquent cast. OrderDetails can be both a query model and a response DTO.

The names are useful only when they expose a design consequence: equality, access, transaction scope, ownership, or transfer. If adding the suffix ValueObject or Aggregate changes none of those things, the code has gained vocabulary without gaining a model.