Data Transfer Objects (DTOs) — what, why, how
Concept
A Data Transfer Object (DTO) is a simple object whose sole purpose is to carry structured data between layers of an application. Unlike Value Objects, DTOs have no business logic and enforce no domain invariants — they are typed envelopes. Unlike Eloquent models, they have no database concerns. They exist to decouple the shape of data coming into a layer (e.g., an HTTP request payload) from the shape of the entity that layer produces (e.g., a domain model or database record).
The problem DTOs solve is coupling through arrays. Before DTOs, a controller would pass $request->all() to a service, which would access $data['email'] and $data['name'] directly. Change a field name and every $data['old_name'] across multiple files breaks — with no static analysis warning because arrays are untyped. A DTO replaces that array with a typed class: if the property name changes, a refactoring tool updates every reference, and PHPStan/Psalm catches mismatches before runtime.
PHP 8.0 constructor property promotion made DTOs dramatically less verbose — you define a property and its constructor parameter in one line. PHP 8.1 readonly properties made them immutable with no extra syntax. PHP 8.2 readonly class makes the entire class immutable with a single keyword. The result is that a DTO can now be expressed in 5–10 lines with full type safety and immutability.
spatie/laravel-data is the dominant DTO library for Laravel. It provides Data base class with automatic validation from form requests, casting (strings to enums, arrays to nested DTOs), JSON serialization, and Livewire integration. Under the hood it uses ReflectionClass to inspect constructor parameters, reads PHP attributes for metadata, and applies type coercion rules similar to Eloquent casting.
The key design decisions when writing a DTO: (1) keep it flat — nested DTOs are fine but avoid deeply nested structures that mirror an ORM graph; (2) validate at the boundary where external data enters the DTO, not inside the DTO's constructor; (3) use backed enums for status fields rather than raw strings; (4) never put repository or service dependencies inside a DTO.
Code Example
<?php
declare(strict_types=1);
// ── Simple readonly DTO (PHP 8.2) ──────────────────────────────────────────
readonly class CreateUserDTO
{
public function __construct(
public string $name,
public string $email,
public string $password,
public string $role = 'member',
public ?string $phone = null,
) {}
}
// ── Nested DTOs ────────────────────────────────────────────────────────────
readonly class AddressDTO
{
public function __construct(
public string $street,
public string $city,
public string $country,
public ?string $postcode = null,
) {}
}
readonly class CreateOrderDTO
{
public function __construct(
public int $userId,
public AddressDTO $shippingAddress,
/** @var array<CreateOrderLineDTO> */
public array $lines,
public string $currency = 'USD',
) {}
}
readonly class CreateOrderLineDTO
{
public function __construct(
public int $productId,
public int $quantity,
public int $unitPriceMinorUnits, // cents
) {}
}
// ── Factory: convert raw request array → DTO ───────────────────────────────
final class OrderDTOFactory
{
public function fromRequest(array $data): CreateOrderDTO
{
$address = new AddressDTO(
street: $data['shipping_address']['street'] ?? throw new \InvalidArgumentException('street required'),
city: $data['shipping_address']['city'] ?? throw new \InvalidArgumentException('city required'),
country: $data['shipping_address']['country'] ?? throw new \InvalidArgumentException('country required'),
postcode: $data['shipping_address']['postcode'] ?? null,
);
$lines = array_map(
fn(array $line) => new CreateOrderLineDTO(
productId: (int) $line['product_id'],
quantity: (int) $line['quantity'],
unitPriceMinorUnits: (int) $line['unit_price'],
),
$data['lines'] ?? []
);
return new CreateOrderDTO(
userId: (int) ($data['user_id'] ?? throw new \InvalidArgumentException('user_id required')),
shippingAddress: $address,
lines: $lines,
currency: $data['currency'] ?? 'USD',
);
}
}
// ── Service consumes the DTO ───────────────────────────────────────────────
final class OrderService
{
public function create(CreateOrderDTO $dto): int
{
// All field access is type-safe; the IDE knows exactly what's available
$total = array_sum(
array_map(fn($l) => $l->quantity * $l->unitPriceMinorUnits, $dto->lines)
);
echo "Creating order for user {$dto->userId}\n";
echo "Ship to: {$dto->shippingAddress->city}, {$dto->shippingAddress->country}\n";
echo "Total: " . number_format($total / 100, 2) . " {$dto->currency}\n";
return random_int(1000, 9999); // pretend DB insert
}
}
// Wire it up
$factory = new OrderDTOFactory();
$dto = $factory->fromRequest([
'user_id' => '42',
'currency' => 'EUR',
'shipping_address' => ['street' => '1 Rue de Rivoli', 'city' => 'Paris', 'country' => 'FR'],
'lines' => [
['product_id' => 1, 'quantity' => 2, 'unit_price' => 1500],
['product_id' => 7, 'quantity' => 1, 'unit_price' => 4999],
],
]);
$service = new OrderService();
$orderId = $service->create($dto);
echo "Order created: {$orderId}\n";Interview Q&A
Q: How does spatie/laravel-data differ from writing a plain PHP DTO, and when does the library overhead pay off?
A plain PHP DTO is just a class with typed constructor parameters — zero dependencies, minimal overhead, works everywhere. spatie/laravel-data's Data base class adds automatic validation from Laravel form request rules, Eloquent cast integration (a property typed to Data sub-class is automatically populated from a JSON column), Livewire form binding, API resource transformation, and TypeScript type generation. The library uses ReflectionClass and PHP attributes to power all of this, which adds a small per-instantiation cost (typically 0.1–1 ms, cached after warm-up). The overhead is worth it in API-heavy or admin-panel projects where you repeatedly solve the same request → DTO → response → validation cycle. For internal domain DTOs that never touch HTTP boundaries, a plain readonly class is usually simpler and faster.
Q: What validation strategy should you use at the DTO boundary versus inside the DTO constructor?
The DTO constructor should enforce structural invariants — things that are always true for a valid DTO regardless of context (non-empty strings, non-negative quantities, valid enum cases). It should throw \InvalidArgumentException or \TypeError for these. Application-level business validation — "does this product ID exist in the database?", "is this user allowed to order in this currency?" — belongs in the service layer before constructing the DTO, or as a dedicated validator/pipeline step. This separation keeps DTOs dependency-free and testable without a database. Never inject repository dependencies into a DTO constructor; that turns a simple data container into a service.
Q: How do you handle optional versus nullable fields in a DTO, and why does the distinction matter?
A nullable field (?string $phone) means the value was explicitly provided as null. An optional field with a default (string $role = 'member') means the value was not provided at all and a default applies. These are different semantics: in a PATCH request, "phone not in the payload" means "don't update phone", while "phone explicitly null" means "set phone to null in the database". To distinguish, some DTOs use a sentinel class like Missing or PHP's \Optional pattern, or they split into "create DTO" (all required) and "update DTO" (all nullable). spatie/laravel-data handles this with its Optional type: a property typed Optional|string is only present in the DTO if the request included the key, allowing PATCH semantics to work correctly.