Destino Dev

Blog

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:

  1. Laravel Telescope ou Debugbar para inspecionar payloads de request.
  2. Logs estruturados: grave $request->all() junto com stack trace na construção do VO.
  3. 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:

  1. Defina a interface Dto::fromRequest(array).
  2. Implemente Value Objects com validação no construtor.
  3. Centralize regras em FormRequest e converta para DTO no controller ou via middleware resolver.
  4. 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