Destino Dev

Blog

Versionamento de APIs com Laravel: rotas, headers, DTOs e compatibilidade

Por John Pires · 18 de março de 2026

Versionamento de APIs com Laravel: rotas, headers, DTOs e compatibilidade

Contexto e motivacao

Problema prático

Projetos de API crescem. As mudanças inevitavelmente quebram consumidores se não forem gerenciadas. Equipes pequenas frequentemente usam hacks (mudar contrato, lançar nova rota ad-hoc) e acumulam débito técnico que aumenta o custo de manutenção. Versionamento planejado reduz risco, dá previsibilidade e permite decompor mudanças grandes em rollout controlado.

Laravel facilita expor múltiplas versões via rotas e middleware, mas requer disciplina: rotas, cabeçalhos, DTOs e contratos de serviço precisam coordenar-se. Sem padrão, você terá duplicação, código condicional espalhado e endpoints inconsistentes.

Requisitos funcionais

Antes de escolher uma estratégia, defina requisitos claros: tempo de vida das versões, política de migração, estratégia de rollback e métrica de uso por versão. Essas decisões influenciam se você usará versionamento por URI, header ou media-type.

Na prática, você deve poder: 1) manter duas versões simultâneas; 2) identificar versão por cliente; 3) evoluir internamente sem impactar outras versões; 4) testar e liberar mudanças gradualmente.

Como funciona por baixo dos panos

Versionamento por rota vs header

Existem dois padrões óbvios: URI (ex: /api/v1/users) e header (ex: Accept: application/vnd.myapp.v2+json ou X-Api-Version: 2). URI é simples e explícito — fácil de cache e de debugar. Header mantém URIs limpas e permite roteamento baseado em conteúdo. Ambos exigem uma camada de resolução de versão para mapear requisições à implementação correta.

Trade-offs: URI facilita proxies e documentação; header permite múltiplas representações no mesmo URI. Escolha com base nos requisitos de clientes e infra.

Resolução de versão e fallback

A resolução de versão é trivial em teoria: ler header/URI/param e mapear. Na prática, implemente fallback e validação. Se o cliente não informar versão, defina um default (normalmente a versão estável mais antiga) e registre métricas. Evite comportamento implícito: registre logs e retorne um header de resposta indicando a versão escolhida.

Internamente, a resolução alimenta três mecanismos: roteamento (qual controller/serviço usar), serialização (DTOs/Resources) e validação (FormRequests). Centralize essa resolução em um middleware ou serviço resolvível por container para evitar ifs espalhados.

Implementacao passo a passo

Estrutura de rotas

Estruture routes/api.php com grupos claros por versão. Isso mantém controllers por versão isolados e facilita deploy paralelo.

Exemplo: rotas simples que apontam para controllers separados por versão. O código abaixo cria os grupos e aponta para controllers distintos.

<?php
use Illuminate\Support\Facades\Route;

Route::group(['prefix' => 'v1', 'middleware' => ['api']], function () {
    Route::get('users', [App\Http\Controllers\Api\V1\UserController::class, 'index']);
    Route::get('users/{id}', [App\Http\Controllers\Api\V1\UserController::class, 'show']);
});

Route::group(['prefix' => 'v2', 'middleware' => ['api']], function () {
    Route::get('users', [App\Http\Controllers\Api\V2\UserController::class, 'index']);
    Route::get('users/{id}', [App\Http\Controllers\Api\V2\UserController::class, 'show']);
});

O que faz: cria dois namespaces de rota, v1 e v2. Depois: cada controller pode evoluir independentemente; caches HTTP funcionam normalmente com URIs versionadas.

Impacto: documentação e clientes ficam explícitos. Contras: duplicação de rota e potencial duplicação de lógica se você não extrair serviços compartilhados.

Middleware de resolução de versão

Se preferir header-based, implemente um middleware que analisa Accept ou um header customizado e sufoca lógica condicional nas controllers. Exemplo funcional abaixo.

<?php
namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;

class VersionResolver
{
    public function handle(Request $request, Closure $next)
    {
        $version = null;

        if ($request->headers->has('X-Api-Version')) {
            $version = $request->header('X-Api-Version');
        } elseif ($accept = $request->header('Accept')) {
            if (preg_match('#vnd\.myapp\.v([0-9]+)\+json#', $accept, $m)) {
                $version = 'v'.$m[1];
            }
        }

        $request->attributes->set('api_version', $version ?? 'v1');

        // opcional: acrescentar header de resposta para debug
        $response = $next($request);
        $response->headers->set('X-Api-Selected-Version', $request->attributes->get('api_version'));

        return $response;
    }
}

O que faz: define atributo request->attributes['api_version'] e adiciona X-Api-Selected-Version na resposta. Depois: todos os componentes podem consultar a versão sem ler headers manualmente.

Observação: escape do regex e verificação adequada são importantes para evitar falsos positivos. Logue requisições sem versão para entender impacto ao descontinuar v1.

Binding do resolvedor e service provider

Prefira injetar um resolvedor de versão via container, assim você pode trocar implementação sem mudar middleware. Exemplo: interface + implementação e binding no provider.

<?php
namespace App\Providers;

use Illuminate\Support\ServiceProvider;
use App\Contracts\ApiVersionResolver;
use App\Services\HeaderVersionResolver;

class VersionServiceProvider extends ServiceProvider
{
    public function register()
    {
        $this->app->singleton(ApiVersionResolver::class, function () {
            return new HeaderVersionResolver();
        });
    }
}

Contexto: o HeaderVersionResolver encapsula a lógica de leitura de headers/URI. Depois: controllers recebem ApiVersionResolver via DI e tomam decisões se necessário.

Benefício: facilita testes unitários e mudanças de estratégia (podemos trocar para URI resolver sem alterar controllers).

Patterns e boas praticas

DTOs e validação

Use DTOs para separar payloads externos do domínio interno. DTOs tornam contratos explícitos e facilitam transformação entre versões. Crie factories fromRequest para conversão e mantenha validação em FormRequest ou validador dedicado.

<?php
namespace App\DTO;

final class UserDTO
{
    public function __construct(
        public int $id,
        public string $name,
        public ?string $email = null,
    ) {}

    public static function fromRequest(array $data): self
    {
        return new self(
            id: (int) ($data['id'] ?? 0),
            name: (string) ($data['name'] ?? ''),
            email: $data['email'] ?? null,
        );
    }
}

O que faz: encapsula campos esperados. Depois: controllers e services trabalham com objetos tipados, eliminando arrays e magic keys. Isso reduz bugs e facilita refactor.

Recomendação prática: mantenha DTOs imutáveis (readonly properties ou não alterar depois de construir). Quando necessário, use mappers para transformar entre DTOs de v1 e v2.

Compatibilidade e contratos

Separe a interface (contrato) da implementação. Exponha interfaces para serviços que implementam comportamento por versão. Isso permite fallback e testes A/B sem duplicar lógica.

Use adapter pattern quando a diferença entre versões for apenas transformacional. Extraia lógica compartilhada em serviços stateless consumidos por múltiplos controllers de versão.

  • Extraia validações comuns em Request classes reutilizáveis.
  • Use Resources (JsonResource) para serialização por versão.
  • Mantenha tests por versão (integração + contract tests).

Armadilhas comuns e como evitar

Quebrar compatibilidade sem versionar

Erro comum: alterar contrato de resposta sem bump de versão. Resultado: clientes falhando em produção. Evite alterando comportamento de campos públicos (renomear, mudar tipo, remover). Se mudar, crie nova versão ou mantenha compat layer que transforma novo formato para antigo.

Se uma mudança pode gerar NullPointer/TypeError em clientes existentes, ela exige versionamento.

Dica prática: adicione testes de regressão que simulam consumo por versão antiga; esses testes detectam impactos inesperados.

Exposição de múltiplas versões e custo de manutenção

Manter muitas versões ativas aumenta custo. Estabeleça uma política clara (por ex. suporte 12 meses). Automatize métricas: registre uso por versão e gere reports para decidir quando deprecar. Deprecação sem oferta de caminho de migração causa churn e tickets.

Outra armadilha: copiar/colar controllers entre versões. Em vez disso, extraia serviços e adapte apenas a borda (controllers/Resources). Isso reduz duplicação e facilita correções de segurança.

Headers e proxies

Quando usar header-based versioning, verifique se proxies e caches respeitam headers customizados. Configure Vary headers corretamente (Vary: Accept, X-Api-Version) para evitar caching incorreto.

Conclusao pratica

Versionamento não é apenas técnica: é contrato com consumidores. Em Laravel, combine rotas versionadas para maior clareza com middleware/service resolver para flexibilidade. Use DTOs e interfaces para isolar mudanças internas e extrair lógica compartilhada em serviços.

Checklist rápido:

  1. Escolha URI ou header com base em proxies, cache e UX.
  2. Centralize resolução de versão em middleware ou serviço injetável.
  3. Use DTOs imutáveis e Resources para serialização por versão.
  4. Extraia serviços comuns para evitar duplicação.
  5. Implemente métricas e política de deprecação.

Implementando essas práticas você reduz risco ao evoluir APIs, mantém código organizado e cria caminho claro de migração para clientes. Comece aplicando middleware de resolução e extraindo um DTO por recurso: pequenos passos que pagam dividendos rapidamente.

Para referência: implemente tests contractuais por versão e um job que coleta uso por versão. Esses dados tornam a descontinuação de versões uma decisão baseada em evidências, não em achismo.

Assuntos: Laravel, PHP, api-versioning, dto, headers

Leia também