DTOs, Serializers, Validators, and Mappers — Keeping API Shapes Separate from the Domain Model

Understand Data Transfer Objects (DTOs) as plain, behavior-free carriers of data distinct from entities; how serializers convert entities to DTOs and DTOs to JSON (and back); how validators check incoming data BEFORE it becomes a domain object; how mappers translate between layers; the practical difference between a DTO and an entity; and the request-model / response-model convention for shaping an API's input and output independently of internal storage.

An HTTP API endpoint and a domain aggregate have different jobs and, almost always, different shapes: the API needs to accept exactly the fields a client is allowed to send and return exactly the fields a client is allowed to see, while the aggregate needs to enforce business invariants and may carry internal fields (an audit trail, a soft-delete flag, an internal risk score) that should never appear in a JSON response. Data Transfer Objects are the pattern for keeping those two shapes intentionally separate instead of accidentally welded together by returning an Eloquent model straight out of a controller. This lesson covers the small cast of supporting characters -- DTOs, serializers, validators, and mappers -- that make that separation practical rather than just theoretical.

What a DTO Is (and Is Not)

A Data Transfer Object is a plain object whose entire job is to carry data across a boundary -- from an HTTP request into application code, or from application code out to a JSON response, or between two layers of a system. A DTO has properties and, at most, trivial constructors/getters; it has NO business logic, no invariant enforcement, and no behavior a domain expert would recognize as 'a rule'. readonly class CreateOrderRequest { public function __construct(public readonly string $customerId, public readonly array $lines) {} } is a DTO: it says nothing about whether the order is valid, only what shape of data arrived.

This absence of behavior is not laziness -- it is the point. A DTO exists purely to be moved, (de)serialized, and read; putting business logic on it would mean that logic runs in whatever context happens to construct the DTO, with no guarantee an invariant was actually checked, which is exactly the guarantee an aggregate root exists to provide. The DTO's job ends the moment its data has been validated and handed off to something that WILL enforce the rules -- usually a domain aggregate.

In PHP 8.1+, DTOs are naturally expressed as readonly classes (immutable after construction) or, for the simplest cases, readonly structs built from plain constructor promotion. Some codebases use associative arrays for this instead; typed readonly classes are strictly better because a typo in a field name becomes a compile-time-visible error (via static analysis) rather than a silent null at runtime.

• DTO = plain data carrier across a boundary; no business logic, no invariant enforcement, ideally immutable. • Its job ends once the data is validated and handed to something (usually a domain aggregate) that DOES enforce rules. • PHP 8.1+ readonly classes with promoted constructor properties are the natural, type-safe way to express a DTO. • Prefer typed DTO classes over raw associative arrays -- a typo in a field name should be a visible error, not a silent null.

DTO versus Entity: Two Different Jobs

The core distinction: an entity enforces invariants and has identity that persists across change; a DTO enforces nothing and exists only for the duration of one transfer. Order (the aggregate from the DDD lesson) refuses to be placed with zero line items -- that rule lives on the aggregate and nowhere else. A CreateOrderRequest DTO built from the same incoming JSON has no such refusal built in; it will happily hold an empty lines array, because checking that rule is not its job -- a validator checks the DTO's shape is plausible, and then the aggregate's own place() method is what actually enforces the business rule when the DTO's data is used to construct or call the domain object.

A second difference is shape: an entity's shape is dictated by what the DOMAIN needs (which may include fields never exposed externally, like an internal riskScore or a createdBy audit field), while a DTO's shape is dictated by what the BOUNDARY needs -- a response DTO deliberately omits internal fields, and a request DTO deliberately accepts only fields a client is allowed to set (never id, createdAt, or status on a create-order request, even if the underlying Order entity has those fields).

Conflating the two -- returning an entity directly as a JSON response, or accepting a request body straight into an entity's constructor -- is one of the most common and costly PHP API mistakes, covered explicitly below.

• Entity: has identity, enforces invariants, its shape follows domain needs. • DTO: no identity of consequence, enforces nothing, its shape follows what the API boundary should expose or accept. • A DTO can validly hold data an entity would reject -- checking that is the validator's and then the entity's job, not the DTO's. • Never let a request DTO's fields include things a client should not be able to set directly (id, status, timestamps).

Serializers: Entity to DTO to JSON, and Back

A serializer converts data between representations -- most commonly, from an entity (or aggregate) into a response DTO, and from that DTO into a JSON string, and the reverse on the way in: JSON into a request DTO. Keeping these as two explicit steps (entity -> DTO, DTO -> JSON) rather than one direct entity -> JSON step is what makes it possible to control exactly which fields leave the system, independent of whatever json_encode(get_object_vars(...)) would have exposed by accident.

A hand-written serializer method on a small mapper class is often clearer and more controllable than a generic reflection-based library for anything beyond a handful of simple fields, because a generic serializer has to guess at what to include (public properties? all properties via reflection? annotated ones?) while a hand-written one states exactly, in one place, which fields go out. Libraries like symfony/serializer are useful at scale for consistent (de)normalization rules across many DTOs, but the underlying discipline -- an explicit, intentional mapping -- is the same either way.

PHP's own JSON handling: json_encode() on an object serializes its PUBLIC properties by default (or the result of JsonSerializable::jsonSerialize() if the class implements that interface), which is precisely why entities with public properties are so easy to leak by accident -- json_encode($order) on an aggregate with any public property will happily include it, whether or not that was the intent.

• Two explicit steps: entity/aggregate -> response DTO -> JSON. Never collapse the first step away. • A hand-written mapper method makes exactly which fields leave the system visible in one place, unlike reflection-based auto-serialization. • json_encode() on a plain object serializes public properties by default, or JsonSerializable::jsonSerialize() if implemented -- both routes can leak fields if not deliberately controlled. • symfony/serializer and similar libraries are useful at scale but implement the same 'explicit, intentional mapping' idea under the hood.

Validators: Checking Shape Before It Becomes Domain

A validator inspects incoming data -- typically a request DTO, or the raw array/JSON before a DTO is even built -- and rejects it early if it is malformed, before that data is allowed anywhere near a domain aggregate. Validation at this layer is deliberately shallow and structural: 'is email a plausible email string', 'is quantity a positive integer', 'is this required field present' -- syntactic and format checks, not business rules. 'A customer cannot place a third order within 24 hours of their last one' is NOT a validator's job -- that is a business invariant that belongs on the domain (an aggregate or a domain service), because it requires business context (looking up the customer's order history) a validator operating on a bare request payload does not have and should not need.

This split matters because it keeps concerns from leaking into the wrong layer: a validator that tries to also enforce business rules ends up duplicating logic that already exists (or should exist) on the aggregate, and the two copies drift out of sync over time. The practical shape in PHP: a validator either throws a ValidationException carrying a list of field-level errors, or returns a Result-style object the caller checks before proceeding -- either way, invalid data never reaches the point where a DTO is constructed and handed to the domain layer.

• Validators check structural/format correctness (types, presence, plausible ranges) on incoming data, before a DTO or domain object is built. • Business rules that require domain context (looking up related data, checking a policy) belong on the domain layer, not the validator. • Collect and report ALL field errors at once where practical, not just the first one found -- better UX for API clients. • A validator commonly throws a ValidationException with a structured list of per-field errors.

Mappers and Request/Response Model Conventions

A mapper is the small, explicit class or function responsible for translating between two shapes -- entity to DTO, DTO to entity constructor arguments, one bounded context's model to another's. Keeping this translation in one dedicated place (rather than scattering $dto->field assignments across several controllers) means there is exactly one place to look when a field is added, renamed, or needs to stop being exposed.

A widely used API convention names DTOs explicitly by direction and purpose: CreateOrderRequest, UpdateOrderRequest (inbound, one per operation, containing only the fields that operation accepts) and OrderResponse, OrderSummaryResponse (outbound, possibly several different response shapes for the same entity depending on context -- a list view needs less detail than a single-order detail view). This is deliberately more verbose than a single shared OrderDTO used everywhere, and that verbosity is the point: each request/response model states, by its very existence and field list, exactly what one specific API operation accepts or returns, and changing what a POST /orders endpoint accepts never accidentally changes what GET /orders/{id} returns.

• A mapper is one dedicated place doing entity <-> DTO translation, instead of scattering ad-hoc field assignments across controllers. • Name request/response DTOs by direction and operation: CreateOrderRequest, OrderResponse, OrderSummaryResponse. • Multiple response shapes for one entity are normal and often correct -- a list view and a detail view rarely need the same fields. • One request DTO per write operation keeps each endpoint's accepted input independently controllable.

Code Examples

The validator only checks shape (types, required keys, positive integers) -- it says nothing about business rules like inventory or credit limits. If validation passes, it returns an immutable DTO; if not, it throws before any domain code runs. Note the DTO classes have no methods that enforce anything -- they are pure carriers.

if ($errors !== []) { throw new ValidationException($errors); } // bad data never reaches the domain layer

Common mistake: Putting a business check like 'this customer is not allowed to order more than $500 without approval' inside the validator duplicates logic that belongs on the domain and will drift out of sync with the real rule over time.

OrderMapper is the single place that knows how to turn an Order (which may internally carry fields never meant for clients) into an OrderResponse containing only what an API consumer should see, in exactly the shape they should see it -- money formatted as a display string, no internal fields, no risk of a future private field on Order silently becoming public JSON.

return new OrderResponse(id: $order->id(), status: ..., lines: $lineResponses, grandTotal: (string) $grandTotal);

Common mistake: Calling json_encode($order) directly on the aggregate, or making Order implement JsonSerializable with an all-fields dump, couples the API's public contract to the domain model's internal shape -- any refactor of Order becomes a breaking API change.

<?php
declare(strict_types=1);

final class ValidationException extends RuntimeException
{
    /** @param array<string,string> $errors */
    public function __construct(public readonly array $errors)
    {
        parent::__construct('Validation failed: ' . implode(', ', array_keys($errors)));
    }
}

// The DTO: pure data, no behavior, no invariant enforcement.
final readonly class CreateOrderLineInput
{
    public function __construct(
        public string $productId,
        public int $quantity,
        public int $unitPriceCents,
    ) {}
}

final readonly class CreateOrderRequest
{
    /** @param CreateOrderLineInput[] $lines */
    public function __construct(
        public string $customerId,
        public array $lines,
    ) {}
}

final class CreateOrderValidator
{
    // Structural/format checks only -- NOT business rules.
    public function validate(array $payload): CreateOrderRequest
    {
        $errors = [];

        if (empty($payload['customer_id']) || !is_string($payload['customer_id'])) {
            $errors['customer_id'] = 'customer_id is required and must be a string.';
        }

        $lines = [];
        if (empty($payload['lines']) || !is_array($pa
…
<?php
declare(strict_types=1);

// Reusing Order/OrderLine/Money from the DDD lesson's aggregate root.

final readonly class OrderLineResponse
{
    public function __construct(
        public string $productId,
        public int $quantity,
        public string $lineTotal, // formatted string -- clients don't need cents math
    ) {}
}

final readonly class OrderResponse
{
    /** @param OrderLineResponse[] $lines */
    public function __construct(
        public string $id,
        public string $status,
        public array $lines,
        public string $grandTotal,
    ) {}
}

final class OrderMapper
{
    // The ONLY place that knows how an Order becomes an OrderResponse.
    public function toResponse(Order $order): OrderResponse
    {
        $lineResponses = array_map(
            static fn (OrderLine $line) => new OrderLineResponse(
                productId: $line->productId,
                quantity: $line->quantity,
                lineTotal: (string) $line->lineTotal(),
            ),
            $order->lines(),
        );

        $grandTotal = array_reduce(
            $order->lines(),
            static fn (Money $carry, OrderLine $line) => $carry->add($line->lin
…

Request/response DTOs around a domain aggregate

The domain aggregate never touches JSON directly, and the DTOs never enforce business rules -- each side does exactly one job.

HTTP JSON in                                            HTTP JSON out
      |                                                         ^
      v                                                         |
+--------------+   validate   +-------------------+   map    +--------------+
| raw payload  | -----------> | CreateOrderRequest | -------> | Order (agg)  |
| (assoc array)|              |   (DTO, no logic)  |          | enforces the |
+--------------+              +-------------------+          | invariants   |
                                                               +--------------+
                                                                     |
                                                               map (serializer)
                                                                     v
                                                             +-----------------+
                                                             | OrderResponse   |
                                                             |  (DTO, no logic)|
                                                             +-----------------+
                                                          
…

Important Rules

A DTO is a plain data carrier: no business logic, no invariant enforcement, ideally immutable (readonly).

Never return an entity/aggregate directly as an API response -- always map to a response DTO first.

Never construct a domain aggregate directly from raw request input -- validate into a DTO, then pass validated data to the aggregate's own constructor/methods.

Validators check structural/format correctness; business rules requiring domain context belong on the aggregate or a domain service.

Keep entity-to-DTO translation in one dedicated mapper, not scattered across controllers.

Name request/response DTOs by direction and specific operation (CreateOrderRequest, OrderResponse) rather than one shared DTO for everything.

Where this pays off

A subscription-billing API kept a strict CreateSubscriptionRequest / SubscriptionResponse pair per endpoint rather than one shared SubscriptionDTO. When the internal Subscription entity later grew an internal proration_ledger_id field for a new billing engine, zero API responses changed, because the mapper's field list -- not the entity's field list -- controlled what clients saw. The same discipline meant a validator rejecting a malformed plan_id never had to duplicate the domain rule that a customer cannot downgrade mid-cycle without an existing credit balance -- that check lived once, on the domain service, exactly where it belonged.

class OrderController { public function show(string $id) { $order = $this->repository->byId($id); return json_encode($order); // leaks whatever fields Order happens to expose } } class OrderController { public function show(string $id) { $order = $this->repository->byId($id); return json_encode($this->mapper->toResponse($order)); // explicit, controlled shape } }

Returning an entity directly couples the API's public JSON contract to the domain model's internal structure -- adding an internal field to Order (an audit note, a risk flag) silently changes what the API returns, and refactoring the entity risks breaking every client. A response DTO built by a dedicated mapper keeps the two shapes independent on purpose.

What is the defining trait of a DTO compared to an entity? A. A DTO must always be stored in a NoSQL document store. B. A DTO carries data across a boundary with no business logic or invariant enforcement, unlike an entity which enforces rules and has identity. C. A DTO is always mutable, while entities are always immutable. D. There is no meaningful difference -- both terms describe the same kind of object.

B. A DTO carries data across a boundary with no business logic or invariant enforcement, unlike an entity which enforces rules and has identity. — A DTO's entire job is to be a plain, ideally immutable carrier of data between layers or across a boundary; it deliberately has no behavior. An entity enforces business invariants and has identity that persists across change -- a fundamentally different responsibility.

Why should a business rule like 'a customer cannot place more than one order within 60 seconds' NOT live inside a request validator? A. Because validators cannot throw exceptions in PHP. B. Because it requires domain context (looking up the customer's order history) that a validator operating on a bare payload does not have, and putting it there duplicates logic that belongs on the domain layer. C. Because validators run after the domain layer, so it would be too late anyway. D. Because PHP validators can only check string length, never business conditions.

B. Because it requires domain context (looking up the customer's order history) that a validator operating on a bare payload does not have, and putting it there duplicates logic that belongs on the domain layer. — Validators check structural/format correctness on data that has no domain context yet. Rules requiring that context (order history, account state, cross-aggregate checks) belong on the domain layer, or the two copies of the rule will drift apart over time.

What problem does returning an entity directly as a JSON API response (json_encode($entity)) commonly cause? A. It is actually the recommended, most efficient approach and has no downside. B. PHP throws a fatal error whenever json_encode() is called on an object. C. It couples the API's public contract to the domain model's internal shape, so any internal refactor or new internal field risks silently changing or breaking what clients receive. D. It makes the response impossible to parse as valid JSON.

C. It couples the API's public contract to the domain model's internal shape, so any internal refactor or new internal field risks silently changing or breaking what clients receive. — json_encode() serializes an object's public properties (or JsonSerializable output) by default. Without an explicit response DTO built by a mapper, the API's shape is at the mercy of whatever the entity happens to expose, which changes as the domain model evolves.

Write an UpdateEmailRequest DTO (readonly, one field: newEmail: string) and an UpdateEmailValidator whose validate(array $payload) method checks that email is present and contains an '@' character, throwing ValidationException otherwise. Then write a small mapper method toSummaryResponse(User $user) returning a UserSummaryResponse DTO with only id and displayName -- deliberately omitting the user's email and password hash.

final readonly class UpdateEmailRequest { public function __construct(public string $newEmail) {} } final class UpdateEmailValidator { public function validate(array $payload): UpdateEmailRequest { if (empty($payload['email']) || !str_contains($payload['email'], '@')) { throw new ValidationException(['email' => 'a valid email is required']); } return new UpdateEmailRequest((string) $payload['email']); } } final readonly class UserSummaryResponse { public function __construct(public string $id, public string $displayName) {} } final class UserMapper { public function toSummaryResponse(User $user): UserSummaryResponse { return new UserSummaryResponse($user->id(), $user->displayName()); } }

The validator should never check anything requiring a database lookup (like 'is this email already taken') -- that belongs on the domain layer, not here.