Laravel Magazine
Building a Versioned REST API with Laravel API Resources

Building a Versioned REST API with Laravel API Resources

Eric Van Johnson ·

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.

Stay Updated

Subscribe to our newsletter

Get latest news, tutorials, community articles and podcast episodes delivered to your inbox.

Weekly articles
We send a new issue of the newsletter every week on Friday.
No spam
We'll never share your email address and you can opt out at any time.