DEV Community

Cover image for Input and Output DTOs: Drawing the Application Boundary in PHP
Gabriel Anhaia
Gabriel Anhaia

Posted on

Input and Output DTOs: Drawing the Application Boundary in PHP


You ship a PlaceOrder use case that returns the Order entity. The controller serializes it to JSON and sends 201. Clean enough.

Six weeks later the order detail endpoint needs the customer's name and the shipping ETA, which the Order aggregate does not hold. So someone adds a getCustomerName() to Order that loads a customer. The mobile team wants total as a formatted string, so a formattedTotal() lands on the entity. The billing export wants the raw cents, so now there are two totals. The aggregate that used to model one thing in the domain is now shaped by three HTTP clients, and every change to a screen drags the domain along with it.

The leak started the moment the use case handed an entity across the boundary. Once the entity is the response, the response's needs become the entity's needs.

Two DTOs draw the line

A use case sits on the application boundary. Everything outside it speaks in primitives, JSON, request bodies. Everything inside speaks in domain types. The boundary needs a contract in both directions, and that contract is two plain objects: an input DTO going in, an output DTO coming out.

<?php

declare(strict_types=1);

namespace App\Application\Order;

final readonly class PlaceOrderCommand
{
    /** @param list<LineInput> $items */
    public function __construct(
        public string $customerId,
        public array $items,
        public string $currency,
        public string $idempotencyKey,
    ) {}
}

final readonly class LineInput
{
    public function __construct(
        public string $sku,
        public int $quantity,
        public int $priceCents,
    ) {}
}
Enter fullscreen mode Exit fullscreen mode

The command carries primitives, not domain types. No CustomerId, no Money, no Order. That is deliberate. The thing constructing the command is an inbound adapter — a controller, a CLI command, a queue worker — and adapters deal in strings and ints from the wire. The command is the shape the use case is willing to accept, written in the dumbest types possible so any caller can build it.

The use case turns primitives into domain types

Inside execute, primitives become domain types. This is where CustomerId, Money, and LineItem get built, where invariants get checked, where the entity comes into being.

<?php

declare(strict_types=1);

namespace App\Application\Order;

use App\Application\Port\CustomerRepository;
use App\Application\Port\OrderRepository;
use App\Domain\Customer\CustomerId;
use App\Domain\Customer\CustomerNotFound;
use App\Domain\Order\LineItem;
use App\Domain\Order\Order;
use App\Domain\Order\OrderId;
use App\Domain\Shared\Money;

final readonly class PlaceOrder
{
    public function __construct(
        private CustomerRepository $customers,
        private OrderRepository $orders,
    ) {}

    public function execute(
        PlaceOrderCommand $cmd,
    ): PlaceOrderResult {
        $customerId = new CustomerId($cmd->customerId);
        if ($this->customers->findById($customerId) === null) {
            throw new CustomerNotFound($customerId);
        }

        $items = [];
        foreach ($cmd->items as $line) {
            $items[] = new LineItem(
                sku: $line->sku,
                quantity: $line->quantity,
                price: new Money($line->priceCents, $cmd->currency),
            );
        }

        $order = Order::place(
            id: OrderId::generate(),
            customerId: $customerId,
            items: $items,
        );

        $this->orders->save($order);

        return PlaceOrderResult::fromOrder($order);
    }
}
Enter fullscreen mode Exit fullscreen mode

The signature is the whole point: PlaceOrderCommand in, PlaceOrderResult out. The Order is born, used, saved, and never escapes. The use case is the only code that holds a live aggregate. Nothing past the boundary gets to touch it.

The result is a snapshot, not the entity

The output DTO is a flat read-model of what the caller needs to know. It is built from the entity at the moment the use case finishes, then the entity is dropped.

<?php

declare(strict_types=1);

namespace App\Application\Order;

use App\Domain\Order\Order;

final readonly class PlaceOrderResult
{
    public function __construct(
        public string $orderId,
        public int $totalCents,
        public string $currency,
        public string $status,
    ) {}

    public static function fromOrder(Order $order): self
    {
        $total = $order->total();
        return new self(
            orderId: $order->id()->value,
            totalCents: $total->amountInMinorUnits,
            currency: $total->currency,
            status: $order->status()->value,
        );
    }
}
Enter fullscreen mode Exit fullscreen mode

fromOrder reads the aggregate through its public methods and copies out flat scalars. After it returns, the Order can be garbage-collected and the result still stands on its own. The result has no behavior, no lazy-loaded relations, no reference back into the domain. It is data.

This is the part people resist, because returning the entity feels like less code. It is less code today. The cost shows up later, when the entity grows a toApiArray() and a forMobile() and a getter that exists only because one endpoint needed it. The result DTO absorbs those pressures instead. Want a different shape for a different client? Add a second result type. The aggregate never hears about it.

Why the use case must not return an entity

Three reasons, each one a real failure that shows up in production code reviews.

Lazy loading across the boundary. If the controller holds a Doctrine-managed Order and calls $order->getCustomer()->getName() during serialization, you have triggered a query outside the use case, possibly outside the transaction, possibly after the entity manager closed. The N+1 that hits your dashboard at 2am started as an innocent getter in a JSON serializer. A flat result DTO has nothing to lazy-load.

The entity's public surface becomes your API contract. Rename a domain method and you break the JSON consumers who were reading it through reflection-based serialization. The entity should be free to refactor its internals. The moment it is also the wire format, every rename is a breaking API change.

Read needs corrupt write models. An aggregate exists to protect invariants on writes. A response exists to satisfy a screen. These have opposite pressures. Tie them together and the aggregate accumulates fields that exist only for display, while the screens are constrained by what the aggregate happens to hold. Separating command-in from result-out is the small version of the CQRS idea: the thing you send to change state is not the thing you read to show state.

Mapping belongs at the edge, not in the controller

Here is the question that decides whether this design holds: where does the JSON become a PlaceOrderCommand?

The wrong answer is to spread it through the controller as inline $request->get('customer_id') calls feeding a fat constructor. That works for one controller. It does not work when the CLI command and the queue worker need the same translation, because now the wire-to-command mapping lives in three places and drifts.

Put the mapping on the command itself, or in a tiny adapter-side factory:

<?php

declare(strict_types=1);

namespace App\Infrastructure\Http\Order;

use App\Application\Order\LineInput;
use App\Application\Order\PlaceOrderCommand;
use Symfony\Component\HttpFoundation\Request;

final class PlaceOrderRequestMapper
{
    public function fromRequest(Request $request): PlaceOrderCommand
    {
        $body = json_decode(
            $request->getContent(),
            associative: true,
            flags: JSON_THROW_ON_ERROR,
        );

        $items = array_map(
            static fn (array $i) => new LineInput(
                sku: (string) $i['sku'],
                quantity: (int) $i['quantity'],
                priceCents: (int) $i['price_cents'],
            ),
            $body['items'] ?? [],
        );

        return new PlaceOrderCommand(
            customerId: (string) ($body['customer_id'] ?? ''),
            items: $items,
            currency: (string) ($body['currency'] ?? 'EUR'),
            idempotencyKey: $request->headers->get(
                'Idempotency-Key',
                '',
            ),
        );
    }
}
Enter fullscreen mode Exit fullscreen mode

The mapper lives in Infrastructure/, next to the controller, because translating an HTTP request into a command is an HTTP concern. The CLI gets its own mapper reading a JSON file. The queue worker gets one reading the message body. Each adapter owns the translation from its own wire format into the same command. The use case never learns there was a Request.

The controller shrinks to three lines that read like a sentence:

<?php

declare(strict_types=1);

namespace App\Infrastructure\Http\Order;

use App\Application\Order\PlaceOrder;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;

final class PlaceOrderController
{
    public function __construct(
        private readonly PlaceOrder $placeOrder,
        private readonly PlaceOrderRequestMapper $mapper,
    ) {}

    public function __invoke(Request $request): JsonResponse
    {
        $command = $this->mapper->fromRequest($request);
        $result = $this->placeOrder->execute($command);

        return new JsonResponse($result, 201);
    }
}
Enter fullscreen mode Exit fullscreen mode

The result DTO serializes to JSON for free, because it is already flat scalars. No serializer config, no @Groups annotations, no circular-reference guards. The shape you return is the shape you defined.

Query side, same shape

Reads follow the identical pattern, with one relaxation. A query input is the parameters of the question. A query result is the read-model the screen wants.

<?php

declare(strict_types=1);

namespace App\Application\Order;

final readonly class GetOrderQuery
{
    public function __construct(public string $orderId) {}
}

final readonly class OrderView
{
    public function __construct(
        public string $orderId,
        public string $customerName,
        public int $totalCents,
        public string $status,
        public string $placedAt,
    ) {}
}
Enter fullscreen mode Exit fullscreen mode

OrderView holds customerName, which the Order aggregate does not. That is fine, and it is the point. A read model is allowed to join across aggregates, because it changes nothing. The query handler can run a raw SQL SELECT with a join and build OrderView directly, skipping the aggregate entirely. You do not load an Order to read an order. The write model protects invariants; the read model answers questions. Letting them differ is what keeps both honest.

The rule, in one line

Primitives cross the boundary going in. Scalars cross it coming out. Domain types live only between the two. If an entity ever appears in a controller signature or a JSON response, the boundary has a hole, and the screens on the far side will widen it.


If this was useful

The input/output DTO boundary is one slice of the larger argument in Decoupled PHP: that an application stays maintainable when the framework is an adapter and the domain is sealed behind use cases. The book walks command and query DTOs, mapping at the edge, and the full read/write split from first principles to a production service, with the same vocabulary used here.

Decoupled PHP — Clean and Hexagonal Architecture for Applications That Outlive the Framework

Available on Kindle, Paperback, and Hardcover. English, German, and Japanese editions out now — Portuguese and Spanish coming soon.

Top comments (0)