API versioning e backwards compatibility

1. Introdução ao Versionamento de APIs em PHP

Versionamento de API é a prática de gerenciar mudanças em interfaces públicas sem quebrar consumidores existentes. Em ecossistemas PHP, especialmente com Laravel, isso é crucial porque aplicações frequentemente expõem endpoints para múltiplos clientes — aplicativos móveis, SPAs, integrações de terceiros — que não podem ser atualizados simultaneamente.

A falta de compatibilidade reversa em microserviços causa falhas em cascata: uma alteração em um endpoint pode derrubar dezenas de serviços dependentes. Diferentes estratégias de versionamento oferecem graus variados de controle:

  • Versionamento por URL: /api/v1/users vs /api/v2/users — simples e explícito, mas polui a estrutura de rotas.
  • Versionamento por header: Accept: application/vnd.api.v1+json — mais elegante, mas exige configuração extra no cliente.
  • Versionamento por media type: combina header e formato de resposta — flexível, porém complexo.

No PHP, a escolha depende do contexto: APIs públicas geralmente preferem URL ou header, enquanto APIs internas podem usar abordagens mais leves.

2. Estratégias de Versionamento no Laravel

Versionamento via Prefixo de Rota

A abordagem mais direta utiliza grupos de rotas com prefixos versionados:

// routes/api.php
Route::prefix('v1')->group(function () {
    Route::get('/users', [V1\UserController::class, 'index']);
});

Route::prefix('v2')->group(function () {
    Route::get('/users', [V2\UserController::class, 'index']);
});

Cada versão tem seu próprio controlador, permitindo evolução independente. A desvantagem é a duplicação de código se as versões compartilham lógica.

Versionamento via Cabeçalhos HTTP

Usando middlewares para interpretar headers personalizados:

// app/Http/Middleware/APIVersion.php
public function handle($request, Closure $next)
{
    $accept = $request->header('Accept');

    if (str_contains($accept, 'application/vnd.api.v2+json')) {
        $request->attributes->set('api_version', 'v2');
    } else {
        $request->attributes->set('api_version', 'v1');
    }

    return $next($request);
}

Em seguida, o controlador pode decidir qual lógica executar baseado na versão:

public function index(Request $request)
{
    $version = $request->attributes->get('api_version', 'v1');

    return $version === 'v2' 
        ? $this->v2Response() 
        : $this->v1Response();
}

Versionamento via Parâmetros de Query

/api/users?version=2 — simples de implementar, mas quebra o princípio RESTful e pode ser facilmente ignorado por clientes. Além disso, URLs com parâmetros não são cacheáveis eficientemente.

Route::get('/users', function (Request $request) {
    $version = $request->query('version', '1');

    // Lógica condicional baseada na versão
});

Limitações principais: poluição de URLs, dificuldade de versionamento de headers, e propensão a erros do lado do cliente.

3. Implementando Backwards Compatibility na Prática

Adicionar Novos Endpoints sem Modificar os Existentes

A regra de ouro: nunca remova ou altere a assinatura de endpoints existentes. Em vez disso, adicione novos:

// V1 - endpoint original
Route::get('/v1/users', [V1\UserController::class, 'index']);

// V2 - novo endpoint com dados adicionais
Route::get('/v2/users', [V2\UserController::class, 'index']);

Se precisar estender um endpoint existente, use parâmetros opcionais:

// Em V1, o campo 'email' não existia
public function index(Request $request)
{
    $users = User::all();

    if ($request->attributes->get('api_version') === 'v2') {
        return UserResource::collection($users);
    }

    return V1UserResource::collection($users);
}

Utilizar Transformers e Presenters

Com o pacote league/fractal ou spatie/laravel-fractal, você pode criar transformadores específicos por versão:

use League\Fractal\TransformerAbstract;

class UserV1Transformer extends TransformerAbstract
{
    public function transform(User $user)
    {
        return [
            'id' => $user->id,
            'name' => $user->name,
            'username' => $user->username,
        ];
    }
}

class UserV2Transformer extends TransformerAbstract
{
    public function transform(User $user)
    {
        return [
            'id' => $user->id,
            'name' => $user->name,
            'email' => $user->email, // Novo campo
            'profile_url' => route('profile', $user),
        ];
    }
}

Estratégias de Depreciação

Headers Deprecation e Sunset informam clientes sobre versões obsoletas:

return response()
    ->json($data)
    ->header('Deprecation', 'true')
    ->header('Sunset', 'Sat, 01 Jan 2025 00:00:00 GMT');

No Laravel, um middleware pode adicionar esses headers automaticamente:

public function handle($request, Closure $next)
{
    $response = $next($request);

    if ($request->attributes->get('api_version') === 'v1') {
        $response->header('Deprecation', 'true');
        $response->header('Sunset', '2025-01-01');
    }

    return $response;
}

4. Versionamento com Laravel e Ferramentas Auxiliares

Grupos de Rotas e Middlewares

Organize versões com middlewares que resolvem a versão automaticamente:

// routes/api_v1.php
Route::middleware('api.version:v1')->group(function () {
    Route::apiResource('users', V1\UserController::class);
});

// routes/api_v2.php
Route::middleware('api.version:v2')->group(function () {
    Route::apiResource('users', V2\UserController::class);
});

VersionResolver Customizado

Crie uma classe que decide a versão baseada em múltiplos fatores:

class VersionResolver
{
    public function resolve(Request $request): string
    {
        // Prioridade: header > URL > query parameter
        if ($header = $request->header('X-API-Version')) {
            return $header;
        }

        if (preg_match('/\/v(\d+)\//', $request->path(), $matches)) {
            return 'v' . $matches[1];
        }

        return $request->query('version', 'v1');
    }
}

Integração com dingo/api

O pacote dingo/api oferece versionamento automático com roteamento inteligente:

$api = app('Dingo\Api\Routing\Router');

$api->version('v1', function ($api) {
    $api->get('users', 'App\Api\V1\Controllers\UserController@index');
});

$api->version('v2', function ($api) {
    $api->get('users', 'App\Api\V2\Controllers\UserController@index');
});

Ele também suporta transformadores e respostas formatadas automaticamente.

5. Evolução de Schemas e Migrações de Dados

Mudanças de Campos entre Versões

Quando um campo é removido ou renomeado, versões antigas ainda precisam dele:

// Migration que adiciona campo, mas preserva o antigo
Schema::table('users', function (Blueprint $table) {
    $table->string('full_name')->nullable(); // Novo campo
    // Campo 'name' permanece para V1
});

Transformers para Campos Obsoletos

class UserV1Transformer extends TransformerAbstract
{
    public function transform(User $user)
    {
        return [
            'name' => $user->name, // Campo legado
            'email' => $user->email,
        ];
    }
}

class UserV2Transformer extends TransformerAbstract
{
    public function transform(User $user)
    {
        return [
            'full_name' => $user->full_name, // Novo campo
            'email' => $user->email,
        ];
    }
}

API Resources do Laravel

Use API Resources para controlar a saída por versão:

class UserResource extends JsonResource
{
    public function toArray($request)
    {
        $version = $request->attributes->get('api_version', 'v1');

        $data = [
            'id' => $this->id,
            'email' => $this->email,
        ];

        if ($version === 'v1') {
            $data['name'] = $this->name;
        } else {
            $data['full_name'] = $this->full_name;
        }

        return $data;
    }
}

6. Testando a Compatibilidade Reversa

Testes de Integração para Versões Antigas

class APIVersioningTest extends TestCase
{
    public function test_v1_users_endpoint_returns_legacy_format()
    {
        $response = $this->getJson('/api/v1/users');

        $response->assertStatus(200);
        $response->assertJsonStructure([
            'data' => [
                '*' => ['id', 'name', 'email']
            ]
        ]);
    }

    public function test_v2_users_endpoint_returns_new_format()
    {
        $response = $this->getJson('/api/v2/users');

        $response->assertStatus(200);
        $response->assertJsonStructure([
            'data' => [
                '*' => ['id', 'full_name', 'email', 'profile_url']
            ]
        ]);
    }
}

Automatização em CI/CD

No phpunit.xml, configure testes específicos para versões:

<testsuites>
    <testsuite name="API V1">
        <directory>tests/Feature/API/V1</directory>
    </testsuite>
    <testsuite name="API V2">
        <directory>tests/Feature/API/V2</directory>
    </testsuite>
</testsuites>

Em pipelines CI, execute ambos os suites para garantir que versões antigas não quebraram.

7. Monitoramento e Estratégias de Descontinuação

Logging de Chamadas a Versões Antigas

// No middleware de versão
public function handle($request, Closure $next)
{
    $version = $this->resolver->resolve($request);

    if ($version === 'v1') {
        Log::channel('deprecated_api')->info('Chamada a V1', [
            'path' => $request->path(),
            'user_agent' => $request->userAgent(),
            'ip' => $request->ip(),
        ]);
    }

    return $next($request);
}

Cronogramas de Depreciação

  1. Anúncio: Comunique a descontinuação com 6 meses de antecedência.
  2. Headers de aviso: Adicione Deprecation e Sunset 3 meses antes.
  3. Redirecionamento: 1 mês antes, redirecione V1 para V2 com aviso.
  4. Remoção: Após a data Sunset, remova a rota e retorne 410 Gone.

Remoção Segura com Fallbacks

Route::fallback(function () {
    return response()->json([
        'error' => 'API version deprecated',
        'message' => 'Please migrate to /api/v2',
        'documentation' => 'https://docs.api.com/migration'
    ], 410);
});

Referências