Skip to content
ExponaBLOG
PHP & LARAVEL DEVELOPMENT · DAY 065 / 100

Laravel API versioning: a practical guide for developers

Change APIs without breaking clients.

Expona Admin7 min read

Introduction

Your mobile app is running version 1.2.3 in production. You've shipped a new API contract that breaks backward compatibility. Do you force all clients to upgrade immediately, or do you run two versions in parallel? Laravel API versioning: a practical guide for developers shows you how to evolve your API without leaving clients stranded. We'll walk through a battle-tested approach that separates concerns, manages deprecation gracefully, and keeps your codebase maintainable as your product scales.

URL Versions — Routes and Separation

The simplest and most explicit way to version an API is by URL path. When you request /api/v1/users, there's no ambiguity about which contract you expect. Laravel makes this straightforward by organizing routes into separate files.

Start by creating versioned route files:

php
routes/
├── api/
│   ├── v1.php
│   ├── v2.php
│   └── v3.php
└── web.php

Register them in RouteServiceProvider:

php
public function map()
{
    $this->mapWebRoutes();
    $this->mapApiV1Routes();
    $this->mapApiV2Routes();
    $this->mapApiV3Routes();
}

protected function mapApiV1Routes()
{
    Route::prefix('api/v1')
        ->middleware('api')
        ->namespace($this->namespace . '\Api\V1')
        ->group(base_path('routes/api/v1.php'));
}

protected function mapApiV2Routes()
{
    Route::prefix('api/v2')
        ->middleware('api')
        ->namespace($this->namespace . '\Api\V2')
        ->group(base_path('routes/api/v2.php'));
}

Now organize your controllers by version:

php
app/Http/Controllers/Api/
├── V1/
│   ├── UserController.php
│   ├── ProductController.php
│   └── OrderController.php
├── V2/
│   ├── UserController.php
│   └── ProductController.php
└── V3/
    └── UserController.php

In routes/api/v1.php:

php
Route::apiResource('users', 'UserController');
Route::apiResource('products', 'ProductController');

In routes/api/v2.php:

php
Route::apiResource('users', 'UserController');
// ProductController is removed or has breaking changes
Route::apiResource('items', 'ItemController');

This structure is explicit, testable, and makes it clear which endpoints exist in which versions. Clients request what they need; you control the entire surface per version.

Version Resources — Separate API Resource Classes

Your database schema doesn't change when you version an API, but the representation does. Use separate Resource classes for each version to transform model data into the expected JSON shape.

Create versioned resources:

php
app/Http/Resources/Api/
├── V1/
│   ├── UserResource.php
│   └── OrderResource.php
├── V2/
│   ├── UserResource.php
│   └── OrderResource.php
└── V3/
    └── UserResource.php

In app/Http/Resources/Api/V1/UserResource.php:

php
namespace App\Http\Resources\Api\V1;

use Illuminate\Http\Resources\Json\JsonResource;

class UserResource extends JsonResource
{
    public function toArray($request)
    {
        return [
            'id' => $this->id,
            'name' => $this->name,
            'email' => $this->email,
            'created_at' => $this->created_at,
        ];
    }
}

In app/Http/Resources/Api/V2/UserResource.php, perhaps we add a phone field and restructure:

php
namespace App\Http\Resources\Api\V2;

use Illuminate\Http\Resources\Json\JsonResource;

class UserResource extends JsonResource
{
    public function toArray($request)
    {
        return [
            'id' => $this->id,
            'name' => $this->name,
            'email' => $this->email,
            'phone' => $this->phone,
            'profile' => [
                'bio' => $this->profile?->bio,
                'avatar_url' => $this->profile?->avatar_url,
            ],
            'created_at' => $this->created_at,
        ];
    }
}

Your controllers import the correct version:

php
// app/Http/Controllers/Api/V1/UserController.php
namespace App\Http\Controllers\Api\V1;

use App\Http\Resources\Api\V1\UserResource;
use App\Models\User;

class UserController extends Controller
{
    public function index()
    {
        return UserResource::collection(User::all());
    }

    public function show(User $user)
    {
        return new UserResource($user);
    }
}
php
// app/Http/Controllers/Api/V2/UserController.php
namespace App\Http\Controllers\Api\V2;

use App\Http\Resources\Api\V2\UserResource;
use App\Models\User;

class UserController extends Controller
{
    public function index()
    {
        return UserResource::collection(User::all());
    }

    public function show(User $user)
    {
        return new UserResource($user);
    }
}

Separating resources per version keeps transformations clean. You can adjust what you return without touching database logic or polluting a single resource class with version conditionals.

Share Logic — Controllers Call the Same Services

Different API versions shouldn't mean duplicated business logic. Extract your core logic into services that both V1 and V2 controllers can use.

Create a service layer:

php
app/Services/
├── UserService.php
├── OrderService.php
└── PaymentService.php

In app/Services/UserService.php:

php
namespace App\Services;

use App\Models\User;

class UserService
{
    public function getAllUsers($limit = 50)
    {
        return User::paginate($limit);
    }

    public function getUserById($id)
    {
        return User::findOrFail($id);
    }

    public function createUser(array $data)
    {
        // Validation, business rules, etc.
        return User::create($data);
    }

    public function updateUser(User $user, array $data)
    {
        $user->update($data);
        return $user;
    }
}

Both version controllers use the same service:

php
// app/Http/Controllers/Api/V1/UserController.php
namespace App\Http\Controllers\Api\V1;

use App\Http\Resources\Api\V1\UserResource;
use App\Services\UserService;

class UserController extends Controller
{
    protected $userService;

    public function __construct(UserService $userService)
    {
        $this->userService = $userService;
    }

    public function index()
    {
        $users = $this->userService->getAllUsers(limit: 50);
        return UserResource::collection($users);
    }

    public function show($id)
    {
        $user = $this->userService->getUserById($id);
        return new UserResource($user);
    }
}
php
// app/Http/Controllers/Api/V2/UserController.php
namespace App\Http\Controllers\Api\V2;

use App\Http\Resources\Api\V2\UserResource;
use App\Services\UserService;

class UserController extends Controller
{
    protected $userService;

    public function __construct(UserService $userService)
    {
        $this->userService = $userService;
    }

    public function index()
    {
        // V2 might use different pagination defaults
        $users = $this->userService->getAllUsers(limit: 100);
        return UserResource::collection($users);
    }

    public function show($id)
    {
        $user = $this->userService->getUserById($id);
        return new UserResource($user);
    }
}

This approach keeps business logic DRY and testable. Services know nothing about API versions; controllers and resources handle presentation concerns. When you fix a bug in UserService, both API versions inherit the fix immediately.

Deprecate — Headers and Documentation

API versioning is only useful if you eventually retire old versions. Give clients time to migrate by announcing sunset dates clearly through response headers and documentation.

Create a deprecation middleware:

php
namespace App\Http\Middleware;

use Closure;

class ApiVersionDeprecation
{
    protected $deprecatedVersions = [
        'v1' => [
            'sunset' => '2025-12-31',
            'message' => 'API v1 is deprecated. Migrate to v2 by December 31, 2025.',
        ],
    ];

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

        // Extract version from route
        $version = $request->route()->getPrefix();
        $version = basename($version); // 'v1', 'v2', etc.

        if (isset($this->deprecatedVersions[$version])) {
            $config = $this->deprecatedVersions[$version];
            
            $response->header('Sunset', $config['sunset']);
            $response->header('Deprecation', 'true');
            $response->header(
                'X-API-Warn',
                $config['message']
            );
        }

        return $response;
    }
}

Apply it to your V1 routes in RouteServiceProvider:

php
protected function mapApiV1Routes()
{
    Route::prefix('api/v1')
        ->middleware(['api', 'api.deprecation'])
        ->namespace($this->namespace . '\Api\V1')
        ->group(base_path('routes/api/v1.php'));
}

Now every V1 response includes deprecation headers. A well-written client library or SDK will read these headers and warn engineers. Document the sunset date prominently in your API documentation and changelog:

API v1 — Sunset December 31, 2025
v1 will be permanently disabled on December 31, 2025. All clients must migrate to v2 or later. See the migration guide for details.

For internal or high-touch clients, send email reminders as the deadline approaches. This gives you a graceful off-ramp: clients have time, they know the deadline, and they can plan upgrades.

Common Mistakes

  • Duplicating all logic. Don't copy service logic into each version's controller. Use shared services and keep version-specific code to routing, resources, and validation rules only.
  • Not documenting changes. Version your API docs alongside code. Use OpenAPI/Swagger specs per version so clients know exactly what changed.
  • Forgetting to test old versions. Maintain tests for deprecated versions until they're actually retired. A regression in V1 should be caught before you deploy.
  • Running too many versions. Supporting V1, V2, V3, V4, and V5 in production is a maintenance nightmare. Retire versions on schedule; don't let them accumulate.
  • Breaking changes without versioning. If you add a new required field to an existing endpoint, that's a breaking change—it needs a new version. Don't sneak changes into an existing version.

Key Takeaways

  • Use URL path versioning (/api/v1, /api/v2) for clarity; it's the industry standard and plays nicely with caching and routing.
  • Create separate route files and controller namespaces per version to make the API surface explicit and testable.
  • Use version-specific Resource classes to transform model data; this keeps presentation concerns separate from business logic.
  • Extract business logic into services that both versions call; don't duplicate code across versions.
  • Announce deprecation via response headers (Deprecation, Sunset) and documentation, then retire versions on schedule.
  • Plan for versioning from day one; retrofitting versioning is painful and error-prone.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles