Building a Versioned REST API with Laravel API Resources
API versioning is one of those things developers often skip until they have to break something. By then it's painful. Building it in from the start is straightforward in Laravel, and the approach outlined here scales well as your API evolves.
The Goal
We'll build a versioned API with:
- URL-based versioning (
/api/v1/,/api/v2/) - Version-specific API Resources for response shaping
- A clean route structure that's easy to extend
Setting Up the Route Structure
Create versioned route files to keep things organized:
routes/
api.php
api_v1.php
api_v2.php
In routes/api.php, load each version's routes inside a versioned prefix group:
// routes/api.php
Route::prefix('v1')->name('api.v1.')->group(base_path('routes/api_v1.php'));
Route::prefix('v2')->name('api.v2.')->group(base_path('routes/api_v2.php'));
Then in each version file, define routes normally:
// routes/api_v1.php
use App\Http\Controllers\Api\V1\UserController;
Route::middleware('auth:sanctum')->group(function () {
Route::apiResource('users', UserController::class);
});
Organizing Controllers
Mirror your route structure in the controller namespace:
app/Http/Controllers/Api/
V1/
UserController.php
V2/
UserController.php
Your V1 controller is a standard resource controller:
<?php
namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller;
use App\Http\Resources\V1\UserResource;
use App\Models\User;
class UserController extends Controller
{
public function index()
{
return UserResource::collection(User::paginate(20));
}
public function show(User $user)
{
return new UserResource($user);
}
}
Versioned API Resources
This is the key to versioning cleanly. Create a resource class per version:
app/Http/Resources/
V1/
UserResource.php
V2/
UserResource.php
The V1 resource returns a simple shape:
<?php
namespace App\Http\Resources\V1;
use Illuminate\Http\Resources\Json\JsonResource;
class UserResource extends JsonResource
{
public function toArray($request): array
{
return [
'id' => $this->id,
'name' => $this->name,
'email' => $this->email,
'created_at' => $this->created_at->toISOString(),
];
}
}
When V2 needs to add fields, rename them, or restructure the response, you create a new V2 resource without touching V1:
<?php
namespace App\Http\Resources\V2;
use Illuminate\Http\Resources\Json\JsonResource;
class UserResource extends JsonResource
{
public function toArray($request): array
{
return [
'id' => $this->id,
'display_name' => $this->name, // renamed
'email' => $this->email,
'avatar_url' => $this->avatar_url, // new field
'role' => $this->role, // new field
'timestamps' => [ // restructured
'created' => $this->created_at->toISOString(),
'updated' => $this->updated_at->toISOString(),
],
];
}
}
V1 clients keep getting the same response shape. V2 clients get the updated structure. No breakage.
Shared Resources With a Base Class
If V1 and V2 share a lot of fields, avoid duplication by extracting common fields into a base resource:
<?php
namespace App\Http\Resources;
use Illuminate\Http\Resources\Json\JsonResource;
class BaseUserResource extends JsonResource
{
protected function coreFields(): array
{
return [
'id' => $this->id,
'email' => $this->email,
];
}
}
Then each version extends it:
<?php
namespace App\Http\Resources\V2;
use App\Http\Resources\BaseUserResource;
class UserResource extends BaseUserResource
{
public function toArray($request): array
{
return array_merge($this->coreFields(), [
'display_name' => $this->name,
'role' => $this->role,
]);
}
}
Adding a Version Header to All Responses
It's useful for API consumers to know which version they're talking to. Add a global middleware:
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
class AddApiVersionHeader
{
public function handle(Request $request, Closure $next, string $version = 'v1')
{
$response = $next($request);
$response->headers->set('X-Api-Version', $version);
return $response;
}
}
Register it on your versioned route groups:
Route::prefix('v1')
->middleware(['api', 'api.version:v1'])
->name('api.v1.')
->group(base_path('routes/api_v1.php'));
Testing Versioned Endpoints
Write feature tests that hit each version's endpoints explicitly:
it('returns v1 user shape', function () {
$user = User::factory()->create();
$this->actingAs($user)
->getJson('/api/v1/users/' . $user->id)
->assertOk()
->assertJsonStructure([
'data' => ['id', 'name', 'email', 'created_at'],
])
->assertJsonMissing(['display_name']); // V2 field should not appear in V1
});
it('returns v2 user shape', function () {
$user = User::factory()->create();
$this->actingAs($user)
->getJson('/api/v2/users/' . $user->id)
->assertOk()
->assertJsonStructure([
'data' => ['id', 'display_name', 'email', 'role', 'timestamps'],
]);
});
These tests act as a contract. If someone accidentally modifies a V1 resource and breaks the shape, the test catches it before it reaches production.
When to Add a New Version
Version boundaries should be meaningful. Add a new version when you need to make a breaking change — removing a field, renaming a key, changing a data type. Don't version for additive changes (new optional fields are backwards-compatible) or internal refactors. The goal is stable contracts for your API consumers, not version sprawl.