- Book: Decoupled PHP — Clean and Hexagonal Architecture for Applications That Outlive the Framework
- Also by me: Database Playbook: Choosing the Right Store for Every System You Build
- My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools
- Me: xgabriel.com | GitHub
A pull request lands in review. Someone added a class called OrderData. It has a customerId string, an amount int, a currency string, and a toArray() method. It's used by the HTTP controller to receive the request body, by the use case as its input, by the repository to map to a row, and by the response serializer to build the JSON reply. One class, four jobs.
The reviewer asks the question every PHP team eventually argues about: is this a DTO or a value object?
The honest answer is that it's neither, and that's the bug. The names matter because the responsibilities are different. Pin down which side of the boundary the type lives on and the decision becomes mechanical.
The one rule
A DTO (Data Transfer Object) carries data across a boundary. HTTP request body to use case input. Use case output to API response. Background job payload to worker. It has no behavior beyond construction. It is allowed to be naive about validity. Its job is transport. Guarantees belong elsewhere.
A value object lives inside the domain and represents a concept with rules. Money, EmailAddress, Sku, Coordinate. Two value objects with the same fields are equal. Identity does not apply. The instance is immutable. It refuses to exist in an invalid state; the constructor throws.
The rule:
If it crosses a boundary, it's a DTO. If it carries meaning inside the domain, it's a value object.
That's it.
The DTO: CreateOrderInput
A DTO is shaped by the boundary it serves. The HTTP controller decodes a JSON body into one of these, hands it to the use case, and that's the end of the DTO's career. It is a courier.
<?php
declare(strict_types=1);
namespace App\Application\Order\Input;
final readonly class CreateOrderInput
{
/**
* @param list<CreateOrderLineInput> $lines
*/
public function __construct(
public string $customerId,
public array $lines,
public string $currency,
public ?string $couponCode = null,
) {}
public static function fromArray(array $payload): self
{
$lines = array_map(
static fn (array $line) => new CreateOrderLineInput(
sku: (string) $line['sku'],
quantity: (int) $line['quantity'],
unitPriceMinor: (int) $line['unit_price_minor'],
),
$payload['lines'] ?? [],
);
return new self(
customerId: (string) $payload['customer_id'],
lines: $lines,
currency: (string) $payload['currency'],
couponCode: isset($payload['coupon_code'])
? (string) $payload['coupon_code']
: null,
);
}
}
final readonly class CreateOrderLineInput
{
public function __construct(
public string $sku,
public int $quantity,
public int $unitPriceMinor,
) {}
}
Notice what is not there:
- No
isValid()method. Validation belongs to the layer that owns the rules, not to the courier. - No business behavior.
CreateOrderInput::total()is the wrong place for that math, because the DTO doesn't know what a valid total looks like. It doesn't know currencies, rounding, or whether quantities can be negative. - No domain types. The currency is a string. The quantity is an int. The DTO speaks the language of the HTTP boundary: JSON and primitives. The domain language stays out.
readonly makes them immutable after construction. final blocks subclassing. Named constructors like fromArray keep the main constructor free for callers that already have typed inputs.
If you have an output DTO going the other way (OrderConfirmedOutput), it follows the same shape: primitives, no behavior, named constructor that builds it from a domain object.
The value object: Money
A value object is shaped by the domain rule it protects. Money is the classic example because everyone gets it wrong with floats, and the wrong version produces silent bugs in invoicing for years.
<?php
declare(strict_types=1);
namespace App\Domain\Shared;
final readonly class Money
{
public function __construct(
public int $amountMinor,
public Currency $currency,
) {
if ($amountMinor < 0) {
throw new \DomainException(
'Money cannot be negative; use a Refund or signed Ledger entry.',
);
}
}
public static function zero(Currency $currency): self
{
return new self(0, $currency);
}
public function add(self $other): self
{
$this->assertSameCurrency($other);
return new self($this->amountMinor + $other->amountMinor, $this->currency);
}
public function subtract(self $other): self
{
$this->assertSameCurrency($other);
if ($other->amountMinor > $this->amountMinor) {
throw new \DomainException('Subtraction would produce negative Money.');
}
return new self($this->amountMinor - $other->amountMinor, $this->currency);
}
public function multiply(int $quantity): self
{
if ($quantity < 0) {
throw new \DomainException('Cannot multiply Money by a negative quantity.');
}
return new self($this->amountMinor * $quantity, $this->currency);
}
public function equals(self $other): bool
{
return $this->amountMinor === $other->amountMinor
&& $this->currency === $other->currency;
}
private function assertSameCurrency(self $other): void
{
if ($this->currency !== $other->currency) {
throw new \DomainException(
"Currency mismatch: {$this->currency->value} vs {$other->currency->value}",
);
}
}
}
enum Currency: string
{
case EUR = 'EUR';
case USD = 'USD';
case GBP = 'GBP';
case BRL = 'BRL';
}
What makes this a value object and not a DTO:
-
Invalid states are refused. Negative amounts throw. Currency mismatches throw. You cannot hold a
Money(-100, EUR)instance, ever. -
Behavior is tied to the concept.
add,subtract,multiply. Each one returns a newMoneybecause the object is immutable. No method mutates$this. -
Comparison is by value.
Money(500, EUR)->equals(Money(500, EUR))is true. They're interchangeable. -
Domain types replace primitives.
Currencyis an enum, not a string. The compiler enforces the closed set. -
There is no identity. No
id. Two equalMoneyobjects are the sameMoneyfor all purposes.
If you find yourself writing Money(-100, ...) somewhere, that is a signal: you wanted a different concept. A Refund, a LedgerEntry with a sign, a Discount. You can't construct a negative Money. The compiler stops you before runtime does.
The conversion: where they meet
The DTO and the value object never overlap inside the application. They meet at exactly one place: the application boundary, where the use case (or a thin mapper next to it) translates between the two.
<?php
declare(strict_types=1);
namespace App\Application\Order;
use App\Application\Order\Input\CreateOrderInput;
use App\Domain\Order\Order;
use App\Domain\Order\OrderId;
use App\Domain\Order\OrderLine;
use App\Domain\Order\Sku;
use App\Domain\Shared\Currency;
use App\Domain\Shared\Money;
final readonly class CreateOrderUseCase
{
public function __construct(
private OrderRepository $orders,
private IdGenerator $ids,
) {}
public function handle(CreateOrderInput $input): OrderId
{
$currency = Currency::from($input->currency);
$lines = array_map(
static fn ($line) => new OrderLine(
sku: new Sku($line->sku),
quantity: $line->quantity,
unitPrice: new Money($line->unitPriceMinor, $currency),
),
$input->lines,
);
$order = Order::place(
id: $this->ids->next(),
customerId: $input->customerId,
lines: $lines,
);
$this->orders->save($order);
return $order->id();
}
}
Three things happen at the boundary:
-
Primitives become domain types.
$input->currency(string) becomesCurrency::EUR(enum).$line->unitPriceMinor(int) becomes aMoney. The string-to-enum conversion is where invalid currencies get caught:Currency::from('XYZ')throws. -
Domain rules engage. The
Moneyconstructor rejects negatives. TheSkuconstructor rejects empty strings. TheOrder::placenamed constructor enforces whatever invariants the order has (at least one line, total above zero, etc.). If anything is wrong, the use case throws a domain exception before anything is persisted. -
The DTO is discarded. Past this point, no code holds a reference to
CreateOrderInput. The order lives in the domain as anOrderaggregate built from value objects. The DTO did its job. It carried the request across the wall.
The same shape applies in reverse. The query side builds a read-model DTO from the domain (or directly from a SQL row), and the HTTP layer serializes it. The domain object never leaks to JSON.
What goes wrong when you mix them
The god-DTO pattern from the opening (one OrderData doing four jobs) fails in three predictable ways.
Validation leaks into the wrong layer. When the controller's request object is the same class the domain uses, you end up either validating in the controller, so the domain trusts unvalidated input, or validating in the domain, so the HTTP layer can't reject a malformed payload with a 400 before any business code runs. Splitting the types lets each layer enforce what it owns: the DTO is shape-checked by the HTTP framework, the value object is rule-checked by its constructor.
The wire format dictates the domain. Say the JSON schema changes. Currency becomes an object with code and symbol. Amounts move to strings to avoid float weirdness in JavaScript. If the same class is used internally, the domain has to change too. With a DTO at the boundary, the wire format changes, the fromArray mapper changes, and the domain doesn't move.
Equality becomes ambiguous. OrderData(500, 'EUR') == OrderData(500, 'EUR'). Are these the same? In PHP, == on objects compares property by property. That works by accident. Then someone adds a ?DateTimeImmutable $createdAt and now two structurally-identical requests are no longer "equal" because one was built a microsecond later. Value objects own their own equals() method; DTOs don't need one because they're not compared.
A decision table
When you're staring at a new class and wondering which it is, walk this list:
| Question | DTO | Value Object |
|---|---|---|
| Does it cross a boundary (HTTP, queue, DB row, external API)? | yes | no |
| Does it represent a domain concept with rules? | no | yes |
| Can it exist in an invalid state? | yes (validation is the next layer's job) | no (constructor throws) |
| Does it have behavior beyond construction? | no | yes |
| Is it compared by value? | no | yes |
| Does it use primitive types? | yes (strings, ints) | no (enums, value objects) |
| Is it immutable? | yes | yes |
Immutability is the only row they share. Everything else points the type at a different layer. A few honest edge cases follow.
-
A bare
Uuidclass. Looks like a value object (immutable, equals by value, constructor validates the format). It is. The fact that it also rides along inside DTOs is fine. Value objects are allowed to appear inside DTOs as fields, as long as the DTO doesn't add behavior on top of them. The reverse is forbidden: DTOs never appear inside the domain. -
Read models. A
CustomerSummaryViewreturned by a query handler is a DTO, even if it never crosses an HTTP wire. It crosses the boundary from the query layer to the caller. Same rules apply. -
Form requests in Laravel / Symfony. These are framework-flavored DTOs. The framework handles shape and basic validation; you map them into a clean
CreateOrderInputbefore handing to the use case so the use case doesn't import the framework.
The migration path when you're already mixed up
Most PHP codebases that hit this problem start with the god-DTO pattern. The cleanup is incremental, not a freeze-week rewrite:
-
Pick the worst offender. Usually the entity at the center of the busiest use case:
Order,User,Invoice. The one that hastoArray,fromRequest,fillFromEloquent, andvalidateall on the same class. -
Introduce the input DTO first. Add
CreateOrderInputas a new class. Change the controller to build it. Change the use case signature to accept it. The domain object stays put for now. Tests still pass because nothing about the persistence layer has moved. -
Introduce the first value object. Pick the field with the most rule density: usually
Money, an email, or an identifier. Replace the primitive everywhere it appears in the domain. The DTO still carries the primitive; the conversion happens in the use case. - Repeat per field, per use case. Each round leaves the codebase compiling and the tests green. Stop whenever the marginal value object stops earning its place. Not every string needs a wrapper.
You will know it's working when a new field added to the JSON request never causes a change to the domain. When you can change the JSON schema without touching the domain, the split is working.
What you keep, what you drop
Keep DTOs at every boundary you control: HTTP in, HTTP out, queue payload, external API client, database row. Keep value objects at every domain concept that has rules. Keep one thin conversion step at the boundary, owned by the use case (or a mapper class next to it). Both stay immutable, both stay final, both get named constructors when the construction is non-trivial.
Drop classes that try to be a DTO and an entity at the same time. Drop validation methods on DTOs. Drop behavior methods on DTOs. Drop primitive obsession inside the domain: string $currency is a code smell once you have an enum.
The naming argument stops mattering once the layers do. Call them whatever your team agrees on. Just don't let one class do both jobs.
If this was useful
This is one slice of the boundary discipline the rest of the book is built on. Decoupled PHP walks the full layout — ports and adapters, use cases, the dependency rule, and the migration playbook for a Laravel or Symfony codebase that already mixed it all together. If your storage layer is where the pain lives, Database Playbook is the companion: choosing the right store for each system you build, and not letting the schema dictate the domain.
Available on Kindle, Paperback, and Hardcover. English, German, and Japanese editions out now — Portuguese and Spanish coming soon.



Top comments (0)