Soms krijg ik vragen van klanten over waarom hun mobiele app niet meer werkt na een API-update. Het probleem zit hem vaak in het feit dat oude versies van hun app nog steeds de oude API-structuur verwachten, terwijl de nieuwe versie andere velden teruggeeft of andere parameters accepteert. Klassieke API-versioning via URL-paths zoals /api/v1/users en /api/v2/users werkt wel, maar leidt tot codeverdubbeling en onderhoudsproblemen. Content negotiation via HTTP headers lost dit eleganter op door één endpoint meerdere representaties te laten serveren.
HTTP Accept headers interpreteren voor versiedetectie
Content negotiation draait om het Accept-header dat clients meesturen. In plaats van een versienummer in de URL te zetten, vraagt de client om een specifieke mediatype-versie. Een moderne client stuurt bijvoorbeeld Accept: application/vnd.api+json;version=2 mee, terwijl een oudere versie Accept: application/vnd.api+json;version=1 gebruikt. De server analyseert dit header en bepaalt welke representatie van de data hij terugstuurt.
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
class ApiVersionNegotiation
{
public function handle(Request $request, Closure $next)
{
$acceptHeader = $request->header('Accept');
$version = $this->extractVersionFromAcceptHeader($acceptHeader);
$request->attributes->set('api_version', $version);
return $next($request);
}
private function extractVersionFromAcceptHeader(?string $acceptHeader): string
{
if (!$acceptHeader) {
return '1'; // Default fallback
}
if (preg_match('/version=(\d+)/', $acceptHeader, $matches)) {
return $matches[1];
}
// Fallback voor clients zonder versie-specificatie
return '1';
}
}
Deze middleware draait voor elke API-route en zet de gevraagde versie vast als request-attribuut. Controllers kunnen hier later op terugvallen zonder dat ze direct met headers hoeven te werken. Het mooie hiervan is dat de URL-structuur hetzelfde blijft, maar de response-structuur kan verschillen per versie.
Resource transformers zorgen ervoor dat dezelfde data op verschillende manieren wordt geformatteerd. Laravel's API Resources zijn hier perfect voor, omdat ze conditionele logica ondersteunen gebaseerd op context-informatie. Een gebruiker-resource kan bijvoorbeeld verschillende velden tonen afhankelijk van welke versie de client verwacht.
<?php
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class UserResource extends JsonResource
{
public function toArray(Request $request): array
{
$version = $request->attributes->get('api_version', '1');
$baseData = [
'id' => $this->id,
'name' => $this->name,
'email' => $this->email,
];
return match($version) {
'2' => array_merge($baseData, [
'profile' => [
'avatar' => $this->avatar_url,
'bio' => $this->bio,
'created_at' => $this->created_at->toISOString(),
],
'preferences' => $this->preferences,
]),
default => array_merge($baseData, [
'avatar' => $this->avatar_url,
'bio' => $this->bio,
'created_at' => $this->created_at->format('Y-m-d H:i:s'),
])
};
}
}
Conditional response formatting met Laravel Resources
Match-expressions maken het switchen tussen versies leesbaar en onderhoudbaar. Versie 2 groepeert gerelateerde velden in nested objecten, terwijl versie 1 een flattere structuur handhaaft. De created_at datum wordt ook anders geformatteerd: ISO 8601 voor moderne clients, traditionele MySQL-formatting voor legacy systemen.
Database queries kunnen ook per versie verschillen. Soms voegt een nieuwe API-versie extra relaties toe die in oudere versies niet bestonden, of worden bepaalde velden niet meer opgehaald voor performance-redenen. Query scopes in Eloquent models helpen hierbij door versie-specifieke data-selectie te encapsuleren.
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
class User extends Model
{
public function scopeForApiVersion(Builder $query, string $version): Builder
{
return match($version) {
'2' => $query->with(['profile', 'preferences', 'subscriptions']),
default => $query->with(['profile'])
};
}
public function profile()
{
return $this->hasOne(UserProfile::class);
}
public function preferences()
{
return $this->hasOne(UserPreferences::class);
}
public function subscriptions()
{
return $this->hasMany(Subscription::class);
}
}
Controllers blijven schoon door de versie-logica naar models en resources te delegeren. De controller hoeft alleen maar de juiste scope aan te roepen en de resource te retourneren. Dit voorkomt dat controllers volgroeien met if-else statements voor elke versie.
<?php
namespace App\Http\Controllers\Api;
use App\Http\Resources\UserResource;
use App\Models\User;
use Illuminate\Http\Request;
class UserController extends Controller
{
public function index(Request $request)
{
$version = $request->attributes->get('api_version', '1');
$users = User::forApiVersion($version)->paginate(15);
return UserResource::collection($users);
}
public function show(Request $request, User $user)
{
$version = $request->attributes->get('api_version', '1');
$user->loadForApiVersion($version);
return new UserResource($user);
}
}
Backward compatibility testing automatiseren
Testing van meerdere API-versies vereist systematische aanpak. Feature tests moeten controleren dat oude clients nog steeds de verwachte response-structuur krijgen, terwijl nieuwe clients toegang hebben tot uitgebreide functionaliteit. Ik schrijf aparte test-klasses per versie om de test-suite georganiseerd te houden.
<?php
namespace Tests\Feature\Api\V1;
use App\Models\User;
use Tests\TestCase;
class UserApiV1Test extends TestCase
{
public function test_users_index_returns_v1_format()
{
User::factory()->count(3)->create();
$response = $this->getJson('/api/users', [
'Accept' => 'application/vnd.api+json;version=1'
]);
$response->assertStatus(200)
->assertJsonStructure([
'data' => [
'*' => [
'id',
'name',
'email',
'avatar',
'bio',
'created_at'
]
]
])
->assertJsonMissing(['profile', 'preferences']);
}
}
<?php
namespace Tests\Feature\Api\V2;
use App\Models\User;
use Tests\TestCase;
class UserApiV2Test extends TestCase
{
public function test_users_index_returns_v2_format()
{
User::factory()->count(3)->create();
$response = $this->getJson('/api/users', [
'Accept' => 'application/vnd.api+json;version=2'
]);
$response->assertStatus(200)
->assertJsonStructure([
'data' => [
'*' => [
'id',
'name',
'email',
'profile' => [
'avatar',
'bio',
'created_at'
],
'preferences'
]
]
]);
}
}
Performance monitoring wordt complexer met meerdere versies omdat verschillende queries en transformaties per versie kunnen draaien. Laravel Telescope helpt hierbij door queries en response-tijden per request te tracken, inclusief de gebruikte API-versie. Dit maakt het mogelijk om per versie performance-bottlenecks te identificeren.
Deprecation warnings zijn cruciaal voor smooth transitions naar nieuwe versies. Clients moeten weten wanneer hun gebruikte versie wordt uitgefaseerd. Custom response headers communiceren dit zonder de response-body te vervuilen.
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
class ApiDeprecationWarning
{
private const DEPRECATED_VERSIONS = ['1'];
private const SUNSET_DATES = [
'1' => '2024-12-31'
];
public function handle(Request $request, Closure $next)
{
$response = $next($request);
$version = $request->attributes->get('api_version');
if (in_array($version, self::DEPRECATED_VERSIONS)) {
$response->headers->set('Deprecation', 'true');
$response->headers->set('Sunset', self::SUNSET_DATES[$version]);
$response->headers->set('Link', '</api/docs/migration>; rel="successor-version"');
}
return $response;
}
}
Documentation en client communication
API-documentatie moet duidelijk maken welke versies beschikbaar zijn en hoe clients de juiste versie kunnen aanvragen. OpenAPI specifications ondersteunen meerdere versies door verschillende schema-definities per versie te documenteren. Clients zien dan precis welke velden beschikbaar zijn in welke versie.
Client libraries kunnen automatisch de juiste Accept-headers instellen gebaseerd op hun versie. Mobile apps die met een oudere SDK zijn gebouwd krijgen automatisch de oude API-versie, terwijl nieuwe apps modern gedrag verwachten. Dit voorkomt breaking changes bij app-updates.
Content negotiation heeft als voordeel dat migration geleidelijk kan gebeuren. Clients updaten op hun eigen tempo zonder dat server-side endpoints verdwijnen. Monitoring laat zien welke versies nog actief worden gebruikt, zodat deprecation-planning gebaseerd is op echte usage-data. Dit geeft een veel gezondere upgrade-cyclus dan hard-coded versie-nummers in URLs die opeens stoppen met werken.