Laravel API Versioning: All You Need To Know About V1/V2

Laravel API Versioning: All You Need To Know About V1/V2
Admin
Monday, July 3, 2023 7 mins to read
Share
Laravel API Versioning: All You Need To Know About V1/V2

Have you ever used an external API and specified its version? For example, have you noticed the /v1/ in the URL of https://api.openai.com/v1/models? In this tutorial, I will explain how to use similar versioning for our own APIs we create with Laravel.

In this long tutorial, we will discuss these topics:

  • WHY we need versioning?
  • How to enable V1 from the beginning
  • WHEN to implement V2?
  • HOW to implement V2?
  • Minor versions like v1.1
  • Date-based version numbers
  • Versioning with headers

A lot to cover. Let's go!


WHY We Need Versioning? What Problem Does It Solve?

If we take OpenAI, for example, here's the screenshot from their API documentation:

Have you noticed the /v1/ as a part of the URL? That's the version.

That "version 1" means stable methods/parameters/results for everyone who would consume that API version. If they introduce breaking changes for some methods, like renaming parameters or returned result structure, that may be a candidate for a future /v2/.

The same logic applies not only to external APIs. What about Laravel packages? If you use, for example, spatie/laravel-permission version 5, you expect it to work stable, but what if they decide to change some methods? They would likely do that in version 6, right?

Even Laravel itself is a package called laravel/framework. And with each new version, there's an upgrade guide with potential breaking changes, small or big. If any of those changes are introduced in a "minor" 10.x version like 10.x and not 11.0, developers may be unhappy because their code would break.

So, in a broader sense, the word API means the interface, the set of strict rules for the available methods, which would be consumed by API client(s). Without the versioning, it would all be unstable, and the projects on the consumer's side would break with any change in the API.

That's why we need v1, v2, etc. Especially if you don't have any control of the consumers of the API, the most significant example is mobile applications: you can't force everyone always to upgrade apps on their phones, right?

Of course, version numbers could have a different logic, like v1.2.4, or even numbered by the release date, like 2023-04-28.

We will talk about all of that in detail below.

Generally speaking, you may not need API versioning if the only consumer of that API is yourself in your front-end code. Then you can make changes on both the front and back end and make it work. But even then, you never know when the project will expand and needs Android/iOS applications and a bigger team of developers. So the earlier you introduce versioning, the better.


Enable V1 From The Beginning

I recently tweeted this:

The tweet became pretty popular, and I will explain it similarly to you here.

Introducing /v1/ as a part of Laravel API endpoints is pretty straightforward:

Step 1. Prefix all the default endpoints from the routes/api.php file with /v1.

It is done in the bootstrap/app.php by setting the apiPrefix in the withRouting() methd.

Later, if you have v2, you will add another Route::group() with v2 prefix:

bootstrap/app.php:

return Application::configure(basePath: dirname(__DIR__))
->withRouting(
web: __DIR__.'/../routes/web.php',
api: __DIR__.'/../routes/api.php',
commands: __DIR__.'/../routes/console.php',
health: '/up',
apiPrefix: 'api/v1'
)
->withMiddleware(function (Middleware $middleware) {
$middleware->alias([
'guest' => \App\Http\Middleware\OnlyGuestAllowedMiddleware::class
]);
})
->withExceptions(function (Exceptions $exceptions) {
//
})->create();

Step 2. When generating Controllers, provide not only /Api as a prefix but also add /V1:

php artisan make:controller Api/V1/ProjectController

I've seen people use uppercase V1 and lowercase v1. It's your personal preference.

Step 3 (optional). Prefix other /app files, too.

php artisan make:resource V1/ProjectResource
php artisan make:request V1/StoreProjectRequest

This one depends on the situation. There's a chance that in v2, those files will stay the same, and you don't need that prefix.

And that's it! You then continue working on V1, and the future V2 creator will be able to write their code without touching/breaking V1.

Of course, if you don't add that v1 from the beginning, it's not the end of the world. Your API will still work. When the time for v2 comes, you have "API with no version that means version 1" and "API with version 2".

But for API consumers, especially if it's a public API, that version number adds an extra feeling of trust that it's a stable version and will likely not change/break soon. So that v1 acts more like a stamp of approval from your team that API is usable.


WHEN To Implement V2?

Before we get to the "how", let's quickly discuss the when.

If you're just adding a few new fields to the database to be returned from the API, this is a non-breaking change, in most cases.

For example, if we take my Booking.com API course and want to add more fields to the API Resource:

app/Http/Resources/ApartmentDetailsResource.php:

class ApartmentDetailsResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
// Older fields
'name' => $this->name,
'type' => $this->apartment_type?->name,
'size' => $this->size,
'beds_list' => $this->beds_list,
'bathrooms' => $this->bathrooms,
'facility_categories' => $this->facility_categories,
 
// New OPTIONAL fields added to the DB
'wheelchair_access' => $this->wheelchair_access,
'pets_allowed' => $this->pets_allowed,
'smoking_allowed' => $this->smoking_allowed,
'free_cancellation' => $this->free_cancellation,
'all_day_access' => $this->all_day_access,
];
}
}

And then add more filters in the API Controller:

app/Http/Controllers/Public/PropertySearchController.php:

class PropertySearchController extends Controller
{
public function __invoke(Request $request)
{
$properties = Property::query()
// Older filters
->when($request->city, function($query) use ($request) {
$query->where('city_id', $request->city);
})
// ->when(...)
// ->when(...)
// ->when(...)
 
// Added new filters:
->when($request->wheelchair_access, function ($query) use ($request) {
$query->whereHas('apartments', function ($query) use ($request) {
$query->where('wheelchair_access', $request->wheelchair_access);
});
})
->when($request->pets_allowed, ...)
->when($request->smoking_allowed, ...)
->when($request->free_cancellation, ...)
->when($request->all_day_access, ...)
->get();
}
}

The example above is NOT a breaking change if all those fields and parameters are introduced as optional. So, you're still on v1, then.

Another example would be "a few breaking changes". If it's just a few methods, you can work around them by introducing new methods or new parameters with default values. Until you actually feel the need for a full v2.

Personally, I like to think about v2 as a whole new version of the entire project. It's the point when you re-think something fundamental in your architecture, change the database structure potentially, and then release the API with new changes.

In other words, it's the time when you can no longer "duct-tape" the current API endpoints without breaking them. You can think about it the same way as when you rewrite the project from scratch: when adding new features on top of the current codebase becomes too "painful" and "hacky".

Also, v2 may be a significant change in how API should be consumed: different authentication mechanisms, rebranding of the project, etc.

Now, let's get to the how.


HOW To Implement V2

The process of introducing a new version depends on how different it is from the current version.

First, we need to take care of the internal non-public changes and only then expose them to API in different versions.

In other words, we first work on Migrations/Models/Factories and all internal data structures.

Then, we decide what and how we will change in the "public" part: in Controllers, Routes, and API Resources if you use them.

With Routes, the typical scenario is simple, as already mentioned above. Add another Route in the withRouting() method within then parameter:

bootstrap/app.php:

return Application::configure(basePath: dirname(__DIR__))
->withRouting(
web: __DIR__.'/../routes/web.php',
api: __DIR__.'/../routes/api.php',
commands: __DIR__.'/../routes/console.php',
health: '/up',
apiPrefix: 'api/v1',
then: function () {
Route::middleware('api')
->prefix('api/v2')
->group(base_path('routes/api_v2.php'));
},
)
->withMiddleware(function (Middleware $middleware) {
//
})
->withExceptions(function (Exceptions $exceptions) {
//
})->create();

Notice: Alternative approach is to use subdomain routing for the versions and have something like v2.yourdomain.com, but personally, I'm not a big fan of this approach, as we're not really dealing with the website URLs here and version numbers may be passed differently in the future, like in Headers (will demonstrate that later).

Then, depending on how drastic the changes are, I see two main approaches:

  • In case of not that many changes: Just copy-duplicate all the Controller/Route files into their V2 folders and then remove/change things one by one
  • In case of many changes: create V2 folders from scratch and start filling the files one by one

Practical Example

Let's imagine an example based on the same Booking.com API course mentioned above.

Let's assume that there's a new requirement to save not only the booking itself but also all the guests with their names and birth dates. A new legal requirement. You can see one of the versions of implementation in this GitHub branch, I will summarize it for you.

Step 1. Internal DB Changes

New DB Table:

Schema::create('booking_guests', function (Blueprint $table) {
$table->id();
$table->foreignId('booking_id')->constrained();
$table->string('first_name');
$table->string('last_name');
$table->date('birth_date');
$table->timestamps();
});

New Model:

app/Models/BookingGuest.php:

class BookingGuest extends Model
{
use HasFactory;
 
protected $fillable = [
'booking_id',
'first_name',
'last_name',
'birth_date',
];
}

New relationship in the old Model:

app/Models/Booking.php:

public function guests()
{
return $this->hasMany(BookingGuest::class);
}

Step 2. V2 Controllers with Updates

In this case, we just copy-pasted all the Controllers into app/Http/Controllers/Api/V2.

The majority of them stayed the same, but changes were made in creating a new booking:

app/Http/Controllers/Api/V2/User/BookingController.php:

use App\Http\Requests\Api\V2\StoreBookingRequest;
 
class BookingController extends Controller
{
public function store(StoreBookingRequest $request)
{
$booking = auth()->user()->bookings()->create($request->validated());
 
$booking->guests()->createMany($request->validated('guests'));
 
$booking->load(['guests']);
 
return new BookingResource($booking);
}
}

Also, we introduce the validation check for storing the booking.

app/Http/Requests/Api/V2/StoreBookingRequest.php:

class StoreBookingRequest extends FormRequest
{
public function rules(): array
{
return [
'apartment_id' => ['required', 'exists:apartments,id', new ApartmentAvailableRule()],
'start_date' => ['required', 'date'],
 
// ... other old fields
 
'guests' => ['required', 'array', 'size:' . $this->input('guests_adults') + $this->input('guests_children')],
'guests.*.first_name' => ['required', 'string'],
'guests.*.last_name' => ['required', 'string'],
'guests.*.birth_date' => ['required', 'date'],
];
}
}

We also changed the structure of what is returned with the booking, adding guests. We have one new API Resource class used within the old class.

app/Http/Resources/Api/V2/BookingGuestResource.php:

class BookingGuestResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'first_name' => $this->first_name,
'last_name' => $this->last_name,
'birth_date' => $this->birth_date,
'created_at' => $this->created_at,
'updated_at' => $this->updated_at,
];
}
}

app/Http/Resources/Api/V2/BookingResource.php:

class BookingResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $this->id,
 
// ... other old fields
 
'guests' => $this->whenLoaded('guests', BookingGuestResource::collection($this->guests))
];
}}

Step 3. Exposing V2 in Routes

I will repeat the same code snippet from above for consistency.

bootstrap/app.php:

return Application::configure(basePath: dirname(__DIR__))
->withRouting(
web: __DIR__.'/../routes/web.php',
api: __DIR__.'/../routes/api.php',
commands: __DIR__.'/../routes/console.php',
health: '/up',
apiPrefix: 'api/v1',
then: function () {
Route::middleware('api')
->prefix('api/v2')
->group(base_path('routes/api_v2.php'));
},
)
->withMiddleware(function (Middleware $middleware) {
//
})
->withExceptions(function (Exceptions $exceptions) {
//
})->create();

And then, inside that new Route file, we have this:

routes/api_v2.php:

use App\Http\Controllers\Api\V2\User\BookingController;
 
Route::middleware('auth:sanctum')->group(function () {
// ...
 
Route::prefix('user')->group(function () {
Route::resource('bookings', BookingController::class)->withTrashed();
});
});

This is just one example change for a few columns related to one Route/Controller. As you can see, it comes down to adding a bunch of V2 namespaces and prefixes everywhere, potentially duplicating a lot of functionality.

If you have more internal classes like app/Services, then you need to make individual decisions: Whether to duplicate them for v1/v2 Or, use the same internal classes, just re-package their results on the Controller/Resource level

Finally, a quick note: remember to write automated tests for both versions. I personally prefer to store them separately, like the tests/Feature/Api/V2 subfolder. I hope you already have tests for the v1, but if you don't, then you have double work to do: you need to ensure that your v2 doesn't break the older v1, as clients will still be using that version. If you are new to testing, I have a Course on Laravel Testing to help you get started.


Why So Drastic: Minor Versions Like v1.1

As I mentioned in the very beginning, Laravel packages also act as APIs, and they release minor versions all the time: Laravel itself has a cycle of weekly minor releases, like v10.3, v10.4, and so on.

Those minor releases should NOT introduce any breaking changes. You probably have seen the negative vibe in the Laravel community if some Laravel 9.x changes the old syntax somewhere.

So, in your APIs, you may go with that incremental approach as well.

Strictly speaking, according to the semantic versioning (semver), the version number vX.Y.Z should follow three parts:

  • X is a MAJOR version, which means breaking changes, often released once every year or a few years
  • Y is a MINOR version, which means new functionality and small non-breaking changes, often released weekly/monthly
  • Z is a PATCH version, which mostly means bug-fixing. Multiple patch versions per day may be released if needed.

But regarding API consumers, you may visually flag only the major version. So, the part of your URL endpoint may be /api/v2/[something], but internally, you would know that you're on v2.13.3, for example.

In other words, the minor/patch versions are for your internal team and for customer support to be able to identify the bugs and which version they were on.

Also, you may put the information on your official documentation page and/or releases page, giving more information to your API consumers about the new functionality or when specific bugs were fixed.


Date-Based Version Numbers: GitHub and Stripe

Until now, we've been referring to new versions as numbers: v1, v2, etc. But you don't have to call them that.

Popular companies like GitHub and Stripe use the API versions based on the date when they were released.

If you use Stripe, you can find this list of versions on your Developer Dashboard:

For GitHub, in 2022, they released a blog post, explaining the change from previously used V3 to the new date-based versioning, starting with the version called 2022-11-28. That version is actually their latest version until this day, at the time of writing this article in July 2023, so for more than 7 months, they didn't need to release any backward-incompatible new version with breaking changes.


Alternative: Versioning with Headers

We are used to the API version being in the URL itself, like v1.domain.com or domain.com/api/v1. But there's another way to do it - using headers. This allows your URL to be clean and have a single endpoint for all the versions. But it comes at a cost, so let's talk about it.

The whole idea behind the header version is to swap Routes without changing the URL. It is done by using a header and processing it in Route Service Provider:

An example is taken from the koel/koel repository.

NOTICE: This example uses older syntax before Laravel 11.

app/Providers/RouteServiceProvider.php

use Illuminate\Foundation\Support\Providers\RouteServiceProvider as ServiceProvider;
use Illuminate\Support\Facades\Route;
use Webmozart\Assert\Assert;
 
class RouteServiceProvider extends ServiceProvider
{
protected $namespace = 'App\Http\Controllers';
 
public function map(): void
{
self::loadVersionAwareRoutes('web');
self::loadVersionAwareRoutes('api');
}
 
private static function loadVersionAwareRoutes(string $type): void
{
Assert::oneOf($type, ['web', 'api']);
 
Route::group([], base_path(sprintf('routes/%s.base.php', $type)));
 
$apiVersion = self::getApiVersion();
$routeFile = $apiVersion ? base_path(sprintf('routes/%s.%s.php', $type, $apiVersion)) : null;
 
if ($routeFile && file_exists($routeFile)) {
Route::group([], $routeFile);
}
}
 
private static function getApiVersion(): ?string
{
// In the test environment, the route service provider is loaded _before_ the request is made,
// so we can't rely on the header.
// Instead, we manually set the API version as an env variable in applicable test cases.
$version = app()->runningUnitTests() ? env('X_API_VERSION') : request()->header('Api-Version');
 
if ($version) {
Assert::oneOf($version, ['v2']);
}
 
return $version;
}
}

Let's break it down:

  • We have a map method that loads the routes for both web and api routes
  • We have a loadVersionAwareRoutes method that loads the base routes and then the versioned routes
  • We have a getApiVersion method that checks the header and returns the version if it's set
  • Our loadVersionAwareRoutes loads the base routes and then checks if the version has been set. If it has, it loads the versioned routes
  • These routes override the base routes, so if you have a route in the base and in the versioned routes, the versioned route will be used

That way, when a client sends you v2 in the header, we will load the v6 routes and override any default ones. It becomes a seamless transition for the client, and you can keep the URL clean without any versioning in it. Here's an example of how the routes look like:

routes/api.php

Route::get('test', function () {
return response()->json(['version' => '1.0.0']);
});

routes/api.v2.php

Route::get('test', function () {
return response()->json(['version' => '2.0.0']);
});

Now if we make a request to domain/test without any headers, we will get this response:

But as soon as we add the Api-Version header with v2 value, we will get this response:

This just allowed us to seamlessly switch between versions without changing the URL. And you can gradually introduce changes.

A great example on how this is done can be found in Stripe documentation. They are using the same approach to version their API.

This approach was also suggested on Twitter:

And the author had some great points!

  • Introducing multiple new files for each version can become messy.
  • Versions can be similar, so you can have a v1 and v1.1 that are very similar, but you need to have them in separate files.
  • Not everything changes in the API version change
  • Headers can be changed faster than v1 to v2 in all the API URLs, which means people are less likely to make mistakes.
  • You have just 1 URL to worry about, not multiple ones for each version.

Bonus: we can also return the current and the newest API versions via headers to warn API consumers about a new version.

First, we will create a new Middleware:

php artisan make:middleware ApiHeadersMiddleware

Then we will register it to our Kernel:

app/Http/Kernel.php

use App\Http\Middleware\ApiHeadersMiddleware;
 
// ...
 
protected $middleware = [
// ...
\Illuminate\Foundation\Http\Middleware\ConvertEmptyStringsToNull::class,
ApiHeadersMiddleware::class,
];

And, of course, we need a place to set our current API version:

config/app.php

// ...
'name' => env('APP_NAME', 'Laravel'),
'latestApiVersion' => 'v2',
// ...

And lastly, we'll modify the Middleware to return the headers:

app/Http/Middleware/ApiHeadersMiddleware.php

// ...
 
public function handle(Request $request, Closure $next)
{
$response = $next($request);
 
$response->header('Api-Version', $request->header('Api-Version', 'v1'));
$response->header('Api-Latest-Version', config('app.latestApiVersion'));
$response->header('Api-Has-Newer-Version', $request->header('Api-Version', 'v1') !== config('app.latestApiVersion'));
 
return $response;
}

That's it. Next time you make a request, you will get these headers:

Now your API clients can check if there's a new version available right from the headers!


Conclusion: "It Depends"

In this lengthy tutorial, we've discussed many details and the most straightforward approaches to API versioning.

But, of course, in real life, examples are much more complicated, so you have to make your own decisions on how to name/release/expose versions. Your main goal should remain the same: not to break anything for your existing API clients while introducing new features to new clients. Good luck with that!