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:
A lot to cover. Let's go!
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.
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/ProjectResourcephp 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.
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.
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:
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.
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:
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.
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.
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:
map
method that loads the routes for both web
and api
routesloadVersionAwareRoutes
method that loads the base routes and then the versioned routesgetApiVersion
method that checks the header and returns the version if it's setloadVersionAwareRoutes
loads the base routes and then checks if the version has been set. If it has, it loads the versioned routesThat 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!
v1
and v1.1
that are very similar, but you need to have them in separate files.v1
to v2
in all the API URLs, which means people are less likely to make mistakes.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!
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!