Value Objects, DTOs, Entities and the Domain Model Toolkit

Understand the core building blocks of a modeled PHP domain layer — Value Objects, DTOs, Entities, repositories, domain services, application services, and domain events — and know precisely how they differ, why immutability matters for Value Objects, and when to reach for each one.

Once a PHP application grows past a handful of controllers and array-shuffling, the difference between 'data that has an identity and a lifecycle' and 'data that is just a value' stops being academic and starts determining how many bugs you ship. A Money amount of $50 is not a thing that changes into $60 — it simply stops being used and a different $60 Money takes its place, the same way the number 5 never 'becomes' 6. An Order, by contrast, genuinely has a lifecycle: the same order is placed, then paid, then shipped, and it is still meaningfully 'the same order' throughout. Value Objects, DTOs, and Entities exist to make that distinction explicit in code, and repositories, domain services, application services, and domain events exist to organize the behavior that surrounds them.

Value Objects: defined by their value, not their identity

A Value Object (VO) is a small, immutable object whose equality is based entirely on its contained data — two Value Objects with the same values ARE equal, regardless of whether they are the same PHP object instance. This mirrors how PHP's own scalars behave: 5 === 5 is true because both are the value five, not because they share a memory address. Money, EmailAddress, DateRange, Coordinates, and PersonName are classic Value Objects — none of them has an identity worth tracking independently of their contents.

Immutability is not a stylistic preference for Value Objects, it is the property that makes them safe to share. Once constructed, a Value Object's internal state never changes; any operation that looks like a mutation — $money->add($other) — actually validates the inputs and returns a brand-new Value Object, leaving the original untouched. This eliminates an entire category of bugs where one part of the code mutates a shared value and a distant, unrelated part of the code is silently affected because it held a reference to 'the same' object.

In modern PHP (8.1+), readonly properties combined with private/protected constructors and named 'named constructor' static factory methods (Money::fromCents(500, 'USD'), EmailAddress::fromString($raw)) are the idiomatic way to build Value Objects: the constructor enforces every invariant the type promises to uphold, so it becomes structurally impossible to hold an instance of the type in an invalid state.

• Value Objects have no identity — equality is by value, comparing all contained fields, not by reference. • Value Objects are immutable — 'mutating' operations always return a new instance rather than modifying in place. • Value Objects validate their own invariants in the constructor/named constructor, so an invalid instance can never exist. • Value Objects are naturally shareable and cacheable precisely because nothing can ever change them out from under a caller.

Building an immutable Value Object with equality semantics

A well-built Value Object needs three things: a constructor (usually private, reached only through named factory methods) that rejects invalid data outright, readonly properties so nothing can mutate it after construction, and an equals() method that compares by value rather than relying on PHP's default object identity comparison. PHP's == operator does compare public properties of two objects of the same class, but relying on that implicitly is fragile — it's easy to add a property later that shouldn't participate in equality (a cached lazily-computed value, for instance), so an explicit equals() method that states exactly which fields matter is far more maintainable and self-documenting.

Money is the textbook example because it also demonstrates why Value Objects usually forbid comparing across incompatible variants: Money::fromCents(500, 'USD')->add(Money::fromCents(300, 'EUR')) should throw rather than silently produce a nonsensical 800-unit value in an undefined currency — the type's job is to make that mistake impossible to express, not just discouraged by convention.

Because a Value Object never changes after construction, every arithmetic-style method (add, subtract, withCurrency) must return a fresh instance built from the current one's data plus the operation's result, and the original instance is left completely intact — callers holding a reference to the 'before' value can trust it never silently changed underneath them.

DTOs vs Entities: transport shape vs identity and lifecycle

A Data Transfer Object (DTO) is a plain, typically immutable bag of data whose only job is to move information across a boundary — from an HTTP request into application code, from application code into a JSON API response, or between layers of a system that shouldn't depend on each other's internal domain types. A DTO has no behavior beyond perhaps simple formatting or validation of its own shape, and critically, a DTO has no identity of its own: a CreateOrderRequest DTO with the same fields as another CreateOrderRequest DTO IS effectively the same request, in the same way two Value Objects with equal data are equal.

An Entity, by contrast, is defined by a persistent identity that survives changes to its other attributes — usually an id field (a database primary key, a UUID) — and it typically has real, meaningful behavior of its own, not just data. Order is an Entity: $order->pay() and $order->ship() change the order's internal state over time, yet it remains 'the same order' throughout, identified by its OrderId, even as its status, its paid amount, and its shipment tracking number all change. Two Order objects with an identical id ARE the same order even if one is a stale copy with outdated field values — identity, not field equality, is what makes them 'the same'.

The practical distinction that matters day to day: DTOs are safe to construct freely, pass around, and discard — they carry no business rules and enforce no invariants beyond basic shape (types, required fields). Entities are where actual business logic and invariant enforcement lives, and they are typically only ever constructed through a repository (when loading existing data) or through explicit domain factory logic (when creating something genuinely new), never assembled ad hoc from raw request data the way a DTO is.

Repositories, domain services, and application services

A repository is the boundary between the domain model and persistence: it exposes an interface that looks like an in-memory collection of Entities (OrderRepository::findById(OrderId $id): ?Order, save(Order $order): void) while hiding the actual SQL, ORM, or storage mechanism behind it. The domain layer depends only on the repository's interface, never on a concrete database client, which is what makes domain logic testable without a real database — a test can implement the same interface as a simple in-memory array and swap it in.

A domain service holds business logic that doesn't naturally belong to any single Entity or Value Object — often because it coordinates several of them, or because the operation is meaningfully about a policy rather than about one object's own state. PricingService::calculateOrderTotal(Order $order, DiscountPolicy $policy): Money is a domain service: the calculation is a real business rule, but it doesn't obviously belong inside Order itself, especially if different discount policies can be swapped in.

An application service (sometimes called a 'use case' or 'command handler') sits one layer up from domain services: it orchestrates a single user-facing operation end to end — load an Entity from its repository, call domain logic/domain services against it, persist the result, and dispatch any domain events — but it deliberately contains no business rules of its own. PlaceOrderApplicationService::handle(PlaceOrderCommand $command): OrderId is application logic: fetch the customer, build the order via domain factory logic, save it via the repository, publish an OrderPlaced event. The distinction matters because domain services are reusable and framework-agnostic, while application services are inherently tied to a specific entry point (an HTTP controller, a CLI command, a queue consumer) and typically aren't reused elsewhere.

Domain events and keeping the model honest

A domain event is an immutable record — itself usually a small Value Object — describing something meaningful that already happened in the domain: OrderPlaced, PaymentReceived, ShipmentDispatched. Entities record events as they change state (an Order::pay() method might append a PaymentReceived event to an internal list) and the application service collects and dispatches those events after a successful save, letting other parts of the system react (send a confirmation email, update analytics, trigger a shipping workflow) without the Order entity itself needing to know anything about email or analytics.

The overall shape that emerges from combining these pieces is deliberate: Value Objects and DTOs are simple, disposable, and safe to pass anywhere; Entities carry identity, state, and business rules, and are protected behind repositories; domain services hold cross-entity business rules; application services orchestrate a single use case using all of the above; and domain events decouple 'what happened' from 'who cares'. None of this is PHP-specific — it is the same vocabulary used across Domain-Driven Design in any language — but PHP's readonly properties, enums, and first-class constructor promotion (8.1+) make the immutable pieces (Value Objects, DTOs, domain events) noticeably less boilerplate to write correctly than in older PHP versions.

Value Object, DTO, and Entity shapes compared

Money's constructor is private — the only way to build one is fromCents(), so an invalid Money can never exist. CreateOrderRequest has no id and no behavior; two instances with identical fields are interchangeable, just like a Value Object. Order carries an OrderId as its identity and mutates its own internal status through explicit, invariant-checking methods like pay() — never through public property writes.

// --- Value Object: immutable, equality by value ---
final class Money
{
    private function __construct(
        public readonly int $amountInCents,
        public readonly string $currency,
    ) {}

    public static function fromCents(int $amountInCents, string $currency): self
    {
        if ($amountInCents < 0) {
            throw new InvalidArgumentException('Money cannot be negative.');
        }
        return new self($amountInCents, strtoupper($currency));
    }

    public function add(Money $other): self
    {
        $this->assertSameCurrency($other);
        return new self($this->amountInCents + $other->amountInCents, $this->currency);
    }

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

    private function assertSameCurrency(Money $other): void
    {
        if ($this->currency !== $other->currency) {
            throw new InvalidArgumentException('Currency mismatch.');
        }
    }
}

// --- DTO: plain data, no identity, no behavior ---
final readonly class CreateOrderRequest
{
    public function __construct(
        public stri
…

Code Examples

EmailAddress can never be constructed with an invalid string because the only entry point, fromString(), validates before the object exists. Its equals() method defines value equality explicitly (case-insensitive) rather than relying on PHP's default identity comparison, which is exactly why $a === $b is false while $a->equals($b) is true. Expected result: bool(false) bool(true) return strtolower($this->value) === strtolower($other->value);

OrderId is a Value Object (equal by value) used as an Entity's identity field. Order itself is an Entity: it has an identity (OrderId) that persists across state changes, and it enforces its own invariant — pay() can only run once, from Pending — by throwing a DomainException rather than allowing a silently inconsistent state. Expected result: Paid Only a pending order can be paid.

PlaceOrderApplicationService is pure orchestration: it builds domain objects (Money, Order), asks the repository interface to persist them, and dispatches a domain event — it contains zero business rules itself. Because it depends only on the OrderRepository interface, InMemoryOrderRepository can stand in for a real database in tests, exercising the full use case without any I/O. Note the command (PlaceOrderCommand) is itself a DTO — plain, immutable, no identity — carrying raw primitive input into the application service, which is exactly the boundary a DTO exists to guard.

public function __construct(private OrderRepository $orders, private EventDispatcher $events) {}

final class EmailAddress
{
    private function __construct(public readonly string $value) {}

    public static function fromString(string $raw): self
    {
        $trimmed = trim($raw);
        if (!filter_var($trimmed, FILTER_VALIDATE_EMAIL)) {
            throw new InvalidArgumentException("Invalid email address: {$raw}");
        }
        return new self($trimmed);
    }

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

$a = EmailAddress::fromString('User@Example.com');
$b = EmailAddress::fromString('user@example.com');

var_dump($a === $b);        // false: different object instances
var_dump($a->equals($b));   // true: same value, case-insensitively
enum OrderStatus { case Pending; case Paid; case Shipped; }

final class OrderId
{
    private function __construct(public readonly string $value) {}

    public static function generate(): self
    {
        return new self(bin2hex(random_bytes(8)));
    }

    public static function fromString(string $value): self
    {
        return new self($value);
    }

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

final class Order
{
    private OrderStatus $status;

    public function __construct(
        public readonly OrderId $id,
        private readonly string $customerId,
    ) {
        $this->status = OrderStatus::Pending;
    }

    public function pay(): void
    {
        if ($this->status !== OrderStatus::Pending) {
            throw new DomainException('Only a pending order can be paid.');
        }
        $this->status = OrderStatus::Paid;
    }

    public function status(): OrderStatus
    {
        return $this->status;
    }
}

$order = new Order(OrderId::generate(), 'cust-123');
$order->pay();
echo $order->status()->name; // Paid

try {
    $order->pay(); // already paid
} catch (DomainException $e) {
  
…
interface OrderRepository
{
    public function findById(OrderId $id): ?Order;
    public function save(Order $order): void;
}

final readonly class PlaceOrderCommand
{
    public function __construct(
        public string $customerId,
        public int $amountInCents,
        public string $currency,
    ) {}
}

final readonly class OrderPlaced
{
    public function __construct(public OrderId $orderId) {}
}

final class PlaceOrderApplicationService
{
    public function __construct(
        private OrderRepository $orders,
        private EventDispatcher $events,
    ) {}

    public function handle(PlaceOrderCommand $command): OrderId
    {
        $total = Money::fromCents($command->amountInCents, $command->currency);

        $order = new Order(
            OrderId::generate(),
            $command->customerId,
            $total,
        );

        $this->orders->save($order);
        $this->events->dispatch(new OrderPlaced($order->id));

        return $order->id;
    }
}

// Test double: no database needed to exercise the use case
final class InMemoryOrderRepository implements OrderRepository
{
    /** @var array<string, Order> */
    private array $orders = [];

    publ
…

How the building blocks fit together

Data flows in as a DTO, is orchestrated by an application service into changes on Entities and Value Objects, persisted through a repository interface, and recorded as domain events for the rest of the system to react to.

HTTP request
       |
       v
  [ DTO: PlaceOrderCommand ]  (plain data, no identity, no rules)
       |
       v
  [ Application Service ]  orchestrates the use case
       |            \
       v             v
  [ Domain Service ]  [ Entity: Order ]  (identity + state + invariants)
  (cross-entity rules)      |
       |                    v
       |            [ Value Objects ]  Money, OrderId, EmailAddress
       v                    |
  [ Repository interface ] <-- save/find
       |
       v
  [ Domain Event: OrderPlaced ] --> dispatched after save

Important Rules

A Value Object has no identity — equality is by value; always make it immutable and validate all invariants at construction time.

A DTO carries data across a boundary and has no behavior and no identity — never put business rules inside a DTO.

An Entity is defined by a persistent identity (usually an id) that survives changes to its other fields, and it is where real invariants and business behavior live.

A repository's interface belongs to the domain layer; only its implementation talks to a real database, ORM, or other storage mechanism.

A domain service holds business rules that don't belong to one specific Entity; an application service orchestrates a single use case with no business rules of its own.

A domain event is an immutable record of something that already happened, dispatched after a successful state change so other parts of the system can react independently.

Where this shows up

Payment processors model money as immutable Value Objects specifically to prevent rounding or reference bugs from corrupting a balance in place. E-commerce platforms separate 'Order' (an Entity with a real lifecycle: pending, paid, shipped, refunded) from the request/response DTOs their API actually exposes, so the internal domain model can evolve without breaking every client integration. Frameworks like Symfony formalize this split directly — Symfony's Messenger component treats commands and events as DTOs dispatched to handlers that act as application services, while Doctrine entities play the Entity role and repositories are first-class objects generated per entity.

final class Money { public int $amountInCents; // mutable public property public string $currency; public function add(Money $other): void { $this->amountInCents += $other->amountInCents; // mutates in place! } } final class Money { private function __construct( public readonly int $amountInCents, public readonly string $currency,) {} public function add(Money $other): self { return new self($this->amountInCents + $other->amountInCents, $this->currency); } }

A Value Object with mutable public properties defeats the entire point of the pattern: any code holding a reference to a Money instance can have it silently change underneath it. Making properties readonly and having every operation return a new instance is what makes Value Objects safe to pass around and share freely.

// Passing an Entity directly out of an API endpoint function getOrder(OrderId $id): Order { return $repository->findById($id); // leaks internal domain object + all its methods }

// Mapping the Entity to a DTO shaped for the API boundary function getOrder(OrderId $id): OrderResponseDto { $order = $repository->findById($id); return new OrderResponseDto($order->id->value, $order->status()->name); }

Returning an Entity directly across an API boundary exposes internal domain behavior and structure to callers who should only see a stable, intentional shape. Mapping to a DTO at the boundary keeps the domain model free to change internally without breaking every API consumer.

[multiple-choice] What makes a Value Object different from an Entity? A. A Value Object always has a database table, an Entity never does B. A Value Object is defined by its value and is immutable and equal-by-value; an Entity is defined by a persistent identity that survives changes to its other fields C. There is no real difference, the terms are interchangeable D. A Value Object can have public mutable properties, an Entity cannot

B. A Value Object is defined by its value and is immutable and equal-by-value; an Entity is defined by a persistent identity that survives changes to its other fields — Value Objects have no identity of their own — two instances with the same data are interchangeable, and they never mutate. Entities carry an identity (typically an id) that persists across state changes, and they are exactly the place where lifecycle-driven business behavior belongs.

[multiple-choice] What is the key difference between a domain service and an application service? A. A domain service holds business rules that don't belong to one Entity; an application service orchestrates a single use case (load, act, save, dispatch events) with no business rules of its own B. A domain service is only used in tests, an application service is only used in production C. They are the same thing with different names D. An application service contains business rules, a domain service only formats output

A. A domain service holds business rules that don't belong to one Entity; an application service orchestrates a single use case (load, act, save, dispatch events) with no business rules of its own — Domain services encapsulate reusable, framework-agnostic business rules that span multiple objects. Application services sit one layer up, orchestrating a specific use case end to end (fetch, mutate via domain logic, persist, dispatch events) but deliberately contain no business logic themselves.

[predict-output] Given the Money class from the syntax block, what does Money::fromCents(500, 'USD')->add(Money::fromCents(300, 'EUR')) do? A. Returns a new Money worth 800 cents with currency 'USD' B. Throws an InvalidArgumentException because the currencies don't match C. Silently returns 0 D. Mutates the original 500-cent Money in place to 800 cents

B. Throws an InvalidArgumentException because the currencies don't match — add() calls assertSameCurrency() before doing any arithmetic, and USD !== EUR, so it throws InvalidArgumentException. This is the Value Object pattern actively preventing an invalid domain state (mixed-currency arithmetic) from ever being representable.

Design an immutable PersonName Value Object with firstName and lastName, a named constructor fromParts(string $first, string $last) that trims whitespace and rejects empty strings, an equals() method comparing both fields, and a fullName(): string method returning "First Last". Then write a small Customer Entity that holds a CustomerId (a Value Object wrapping a string id) and a PersonName, exposing a rename(PersonName $newName): void method that replaces the name — demonstrating that the Entity's identity (CustomerId) never changes even though its PersonName value does.

final class PersonName { private function __construct( public readonly string $firstName, public readonly string $lastName, ) {} public static function fromParts(string $first, string $last): self { $first = trim($first); $last = trim($last); if ($first === '' || $last === '') { throw new InvalidArgumentException('Name parts cannot be empty.'); } return new self($first, $last); } public function equals(PersonName $other): bool { return $this->firstName === $other->firstName && $this->lastName === $other->lastName; } public function fullName(): string { return "{$this->firstName} {$this->lastName}"; } } final class CustomerId { private function __construct(public readonly string $value) {} public static function fromString(string $value): self { return new self($value); } } final class Customer { public function __construct( public readonly CustomerId $id, private PersonName $name, ) {} public function rename(PersonName $newName): void { $this->name = $newName; } public function name(): PersonName { return $this->name; } }

Keep PersonName's constructor private, reached only via fromParts(); Customer should store PersonName as a private mutable property that rename() reassigns wholesale (never edited field-by-field), since the whole point is that a new PersonName Value Object replaces the old one rather than being mutated in place.