APIs type-safe no Laravel: DTOs tipados, Value Objects e validação centralizada
Por John Pires · 19 de março de 2026

APIs type-safe no Laravel: DTOs tipados, Value Objects e validação centralizada
O foco aqui é direto: padrões aplicáveis em projetos Laravel para reduzir bugs de runtime, centralizar validação e expressar invariantes de domínio com DTOs tipados e Value Objects. Código e convenções reais, pronto para aplicar.
Motivacao e trade-offs
APIs REST em Laravel frequentemente aceitam arrays soltos do request e passam para serviços ou Eloquent diretamente. Isso funciona, mas cria pontos fracos: falta de contratos claros, mistura de responsabilidades (validação e transformação) e necessidade constante de testes para checar formatos esperados.
Usar DTOs (Data Transfer Objects) e Value Objects torna as entradas explicitamente tipadas, empurra validação para camadas previsíveis e melhora o autocompletar e análise estática. Trade-offs: mais código de infraestrutura e necessidade de tornar a construção de DTOs conveniente para não duplicar regras.
Benefícios práticos
Listagem objetiva do que ganhamos:
- Contratos claros entre controller e camada de aplicação.
- Invariantes» validadas ao criar Value Objects (ex.: Email, Money), evitando checagens espalhadas.
- Testabilidade — DTOs podem ser instanciados diretamente em testes unitários sem HTTP.
Custos e quando evitar
Pequenos projetos ou protótipos onde velocidade de iteração importa podem sofrer com esse overhead. Também há custo inicial de disciplina: definir de onde virá a validação (FormRequest, regras centralizadas, middleware).
Regra prática: prefira DTOs quando a entrada exige transformação, validação não-trivial ou quando a mesma estrutura é usada em vários endpoints.
Fundamentos tecnicos
Vou assumir PHP 8.3+ e Laravel 10+. Usaremos:
- Classes readonly e property promotion para DTOs.
- Value Objects imutáveis com validação no construtor.
- FormRequest ou um resolver central para validar e construir DTOs.
Contrato mínimo de DTO
Defina um contrato simples que todos os DTOs implementam para uniformizar a criação a partir de arrays validados.
<?php
namespace App\DTO;
interface Dto
{
public static function fromRequest(array $validated): self;
}
Contexto: essa interface permite um resolver (middleware ou provider) construir instâncias automaticamente quando a action do controller exige aquele tipo.
Resultado: chamando App\DTO\CreateUserDTO::fromRequest(['name'=>..., 'email'=>...]) você obtém um DTO garantido.
Value Objects: invariantes no construtor
Value Objects validam invariantes no momento da construção e expõem operações de domínio. Isso evita repetir validações de formato por toda a aplicação.
<?php
namespace App\ValueObjects;
final class Email
{
public function __construct(public readonly string $value)
{
if (!filter_var($this->value, FILTER_VALIDATE_EMAIL)) {
throw new \InvalidArgumentException("Invalid email: {$this->value}");
}
}
public function __toString(): string
{
return $this->value;
}
}
Contexto: Email encapsula a regra de formato. Depois de criado, qualquer uso assume que o valor é válido.
Resultado: substitua checks de formato espalhados por instâncias de Email. Fallbacks são tratados com try/catch ao receber input não confiável.
Exemplo 1: caso simples
Implementaremos um fluxo de criação de usuário com validação centralizada via FormRequest, DTO tipado e Value Object para email.
CreateUserRequest (FormRequest)
Regras centralizadas dentro de um FormRequest. O FormRequest garante 422 em falha e deixa controller limpo.
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class CreateUserRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'name' => 'required|string|max:255',
'email' => 'required|email|unique:users,email',
'password' => 'required|string|min:8',
];
}
}
Contexto: quando este request é injetado no controller, o Laravel já validou os dados ou abortou com resposta 422.
Resultado: controller não precisa lidar com regras de validação.
CreateUserDTO e Controller
DTO tipado que transforma o array validado em tipos do domínio (Email, plaintext password). O controller usa apenas o DTO.
<?php
namespace App\DTO;
use App\ValueObjects\Email;
final readonly class CreateUserDTO implements Dto
{
public function __construct(
public string $name,
public Email $email,
public string $password
) {}
public static function fromRequest(array $validated): self
{
return new self(
$validated['name'],
new Email($validated['email']),
$validated['password']
);
}
}
// Controller
namespace App\Http\Controllers;
use App\Http\Requests\CreateUserRequest;
use App\DTO\CreateUserDTO;
use App\Models\User;
use Illuminate\Support\Facades\Hash;
class UserController
{
public function store(CreateUserRequest $request)
{
$dto = CreateUserDTO::fromRequest($request->validated());
$user = User::create([
'name' => $dto->name,
'email' => (string) $dto->email,
'password' => Hash::make($dto->password),
]);
return response()->json($user, 201);
}
}
Contexto: o controller recebe um DTO, não um array. Tudo que resta é transformação e persistência.
Resultado: menos erros por parâmetro ausente ou malformatado; testes unitários podem instanciar CreateUserDTO diretamente.
Exemplo 2: caso avancado do mundo real
Agora um endpoint de criação de pedido que usa listas de items, Money value object e um resolver para injetar DTOs automaticamente.
Money e ProductId Value Objects
Money representa valores em centavos, garante não-negatividade e operações aritméticas simples.
<?php
namespace App\ValueObjects;
final class Money
{
public function __construct(public int $amount, public string $currency = 'BRL')
{
if ($this->amount < 0) {
throw new \InvalidArgumentException('Money amount must be >= 0');
}
}
public function add(Money $other): Money
{
if ($this->currency !== $other->currency) {
throw new \InvalidArgumentException('Currency mismatch');
}
return new Money($this->amount + $other->amount, $this->currency);
}
public function toFloat(): float
{
return $this->amount / 100;
}
}
final class ProductId
{
public function __construct(public string $value)
{
if (!preg_match('/^[0-9a-f\-]{36}$/', $this->value)) {
throw new \InvalidArgumentException('Invalid product id');
}
}
}
Contexto: Items no pedido usarão ProductId e Money; assim você evita usar strings soltas.
OrderCreateDTO e resolução automática
DTO para criar pedidos. Aqui mostramos também um middleware resolver simplificado que instancia DTOs que implementam a interface Dto.
<?php
namespace App\DTO;
final readonly class OrderCreateDTO implements Dto
{
public function __construct(
public string $customerId,
public array $items, // each item: ['productId' => ProductId, 'quantity' => int, 'unitPrice' => Money]
) {}
public static function fromRequest(array $validated): self
{
$items = array_map(function ($i) {
return [
'productId' => new \App\ValueObjects\ProductId($i['product_id']),
'quantity' => (int) $i['quantity'],
'unitPrice' => new \App\ValueObjects\Money((int) round($i['unit_price'] * 100)),
];
}, $validated['items']);
return new self($validated['customer_id'], $items);
}
}
// Middleware simplificado: resolve DTO constructor from FormRequest validated data
namespace App\Http\Middleware;
use Closure;
use ReflectionMethod;
class ResolveDtoMiddleware
{
public function handle($request, Closure $next)
{
$route = $request->route();
$actionName = $route->getActionName();
if (!str_contains($actionName, '@')) {
return $next($request);
}
[$controllerClass, $method] = explode('@', $actionName);
if (!class_exists($controllerClass)) {
return $next($request);
}
$ref = new ReflectionMethod($controllerClass, $method);
foreach ($ref->getParameters() as $param) {
$type = $param->getType();
if (!$type || $type->isBuiltin()) {
continue;
}
$class = $type->getName();
if (is_subclass_of($class, \App\DTO\Dto::class)) {
// try to use validated() if request is FormRequest, else all()
$validated = method_exists($request, 'validated') ? $request->validated() : $request->all();
$dto = $class::fromRequest($validated);
// bind instance in container so Laravel's controller can type-hint it
app()->instance($class, $dto);
}
}
return $next($request);
}
}
Contexto: esse middleware verifica os parâmetros do método e instancia DTOs a partir dos dados validados. Em um app real, registre o middleware antes dos controllers.
Resultado: controllers podem declarar OrderCreateDTO como dependência e receber uma instância já construída.
Debugging e troubleshooting
Ao introduzir DTOs e Value Objects, surgem erros típicos: exceções no construtor do VO, dados faltando no validated array, ou binding falho do resolver. Aqui estão dicas práticas para resolver rapidamente.
Erros comuns
- InvalidArgumentException lançado do ValueObject: sinal de entrada malformatada — trate no nível de request ou transforme com try/catch e retorne 422 com mensagem clara.
- Resolver não constrói DTO porque validated() não existe — garanta que um FormRequest esteja sendo usado ou caia para $request->all().
- Injeção falha: o controller recebe sempre o Request: verifique ordem do middleware e se app()->instance foi chamado com a classe correta.
// Exemplo de try/catch no Controller quando VO pode lançar
public function store(CreateUserRequest $request)
{
try {
$dto = CreateUserDTO::fromRequest($request->validated());
} catch (\InvalidArgumentException $e) {
return response()->json(['message' => $e->getMessage()], 422);
}
// persistir...
}
Contexto: captura falhas de construção e converte em resposta JSON legível para cliente.
Ferramentas e técnicas
Use estas ferramentas para acelerar o troubleshooting:
- Laravel Telescope ou Debugbar para inspecionar payloads de request.
- Logs estruturados: grave $request->all() junto com stack trace na construção do VO.
- Integração com phpstan/psalm para tipagem e análise estática dos DTOs.
Dica: escreva testes unitários criando DTOs com dados válidos e inválidos. Um bom teste de DTO detecta regressões ao mudar regras de validação.
Conclusao pratica
Adotar DTOs tipados, Value Objects e validação centralizada reduz bugs de integração, melhora a legibilidade do código e torna APIs Laravel mais previsíveis. O custo é código adicional e disciplina para manter regras de transformação em um lugar só.
Comece pequeno: converta endpoints críticos para esse padrão, escreva Value Objects para tipos que aparecem frequentemente (email, money, identifier) e escolha se quer FormRequests + DTO::fromRequest ou um resolver automático para construir DTOs.
Resumo de passos práticos:
- Defina a interface Dto::fromRequest(array).
- Implemente Value Objects com validação no construtor.
- Centralize regras em FormRequest e converta para DTO no controller ou via middleware resolver.
- Adicione testes unitários e análise estática.
Aplicando esse conjunto você terá APIs mais robustas e contratos explícitos entre camada HTTP e domínio.
Assuntos: PHP, Laravel, dto, value-objects, api, validation
Leia também
Autenticação de API com Laravel Sanctum para SPA e mobile — passo a passo
Guia prático para autenticar SPA e apps mobile com Laravel Sanctum: compare o jeito errado (token no localStorage) com o certo (cookies para SPA, tokens com…
Circuit breaker para APIs externas com Laravel Http Client e Redis
Implementação prática de circuit breaker em Laravel usando Redis e Http Client. Mostra o jeito errado (flag em memória, retries inline) e a solução robusta…
ETags e cache condicional em APIs Laravel (PHP 8.3): reduzir banda e latência
Como implementar ETags e cache condicional em APIs Laravel (PHP 8.3) para reduzir banda e latência: patterns práticos, middleware, fingerprint por coleção e…
Versionamento de APIs com Laravel: rotas, headers, DTOs e compatibilidade
Práticas avançadas para versionar APIs em Laravel: rotas vs headers, middleware de resolução, DTOs imutáveis, contratos e estratégias para manter…
Laravel 10: consumir APIs externas com Http Client, retries, fallback e cache
Guia prático para iniciantes em Laravel 10 sobre como consumir APIs externas usando Http Client: timeout, retries, cache e fallback, com exemplos errados e…
Filament: importar CSV com jobs na fila e mostrar progresso em Livewire
Tutorial prático para iniciantes: configure fila no Laravel, crie upload no Filament, dispare um Job que processa CSV e atualize um componente Livewire com…