API versioning in Laravel applications

API versioning is an important and often complex task, which most likely does not have any universal solution. I’m talking about one of the possible approaches in Laravel-based applications.

However, I’ll make a reservation right away – this method can be implemented not only in Laravel applications, and it may not be suitable for everyone. Implementing the described method may require a lot of refactoring, in which case it will be faster and easier to go (perhaps) along the copy-pasting route.

Why version the API?

Applications often require API versioning when the following events occur:

  1. The API is public;

  2. The API has a certain number of consumers for whom backward compatibility is extremely important;

  3. Inversely incompatible changes must be made to input and/or output data structures.

Of course, these are not the only reasons when versioning may be required, but in my experience this need arose precisely in this case.

How can you version the API?

In most articles on the topic of versioning, more often stands out One way is to create copies of controllers, requests and handlers, and place them under the new version prefix (for example, /v2). In general, the method from this article is similar, however, the approach is slightly different – I suggest (when possible) version only queries and transformers (that is, what your API response generates).

Principle

The principle is quite simple in words – API request handlers do not need to know what version they are running under. If they are general enough to handle requests from different versions, they need to provide all the necessary information with versioned requests.

Let's take the example of a handler that registers a customer in an online store. He receives a request with the buyer’s phone number, country, name and password. The handler must create a record, send an SMS with a registration confirmation code and return a successful response to the request.

<?php

namespace App\Http\Controllers;

use App\Jobs\SendRegistrationCode;
use App\Models\Customer;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;

class CustomerRegistrationApiController
{
    public function store(Request $request): JsonResponse
    {
        $existedCustomer = Customer::where('phone', $request->input('phone_number'))->first();

        if (!is_null($existedCustomer)) {
            throw new \InvalidArgumentException('Покупатель уже зарегистрирован!');
        }

        $customer = Customer::create([
            'phone' => $request->input('phone_number'),
            'phone_country' => $request->input('phone_country') ?? 'RU',
            'password' => bcrypt($request->input('password')),
            'name' => $request->input('name'),
            'surname' => $request->input('surname'),
        ]);

        dispatch(new SendRegistrationCode($customer));

        return response()->json([
            'created' => true,
            'id' => $customer->id,
        ]);
    }
}

Now let's say we needed to rename some fields. Instead of fields phone_number And phone_country we want to use the object phone with margins phone.number And phone.countryinstead of namefirst_nameand instead surnamelast_name. In the response instead of a field id we want to send only the phone number.

Let's see how this can be done in different ways.

The first way is to check the version

To begin with, you can collect fields by checking the current version. If the version is not specified or v1use the old fields if the version is equal v2 – we take data from new ones. Let's assume that the version number is passed in the route parameter.

<?php

namespace App\Http\Controllers;

use App\Jobs\SendRegistrationCode;
use App\Models\Customer;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;

class CustomerRegistrationApiController
{
    public function store(Request $request, ?string $version = null): JsonResponse
    {
        $data = match (true) {
            // Версия не указана или равна первой – используем старые поля.
            is_null($version) || $version === 'v1' => [
              'phone' => $request->input('phone_number'),
              'phone_country' => $request->input('phone_country') ?? 'RU',
              'password' => bcrypt($request->input('password')),
              'name' => $request->input('name'),
              'surname' => $request->input('surname'),
            ],
            $version === 'v2' => [
              'phone' => $request->input('phone.number'),
              'phone_country' => $request->input('phone.country') ?? 'RU',
              'password' => bcrypt($request->input('password')),
              'name' => $request->input('first_name'),
              'surname' => $request->input('last_name'),
            ],
            default => throw new \InvalidArgumentException('Invalid data.'),
        };

        $existedCustomer = Customer::where('phone', $data['phone'])->first();

        if (!is_null($existedCustomer)) {
            throw new \InvalidArgumentException('Покупатель уже зарегистрирован!');
        }

        $customer = Customer::create($data);

        dispatch(new SendRegistrationCode($customer));

        $response = match (true) {
            is_null($version) || $version === 'v1' => [
              'created' => true,
              'id' => $customer->id,
            ],
            $version === 'v2' => [
                'phone' => $customer->phone,
            ],
        };

        return response()->json($response);
    }
}

Technically this will work, and if you have a small API it might be sufficient. However, the more controllers/handlers have to be rewritten in this way, the more difficult it will be to maintain them and introduce new versions.

In addition, you will most likely use request validation. With this approach, you will have to add rules like required_without:to ensure that at least one required field has been passed in (however, this may miss requests where one data is passed in a field from the version v1and others – in the field from the version v2).

The second method is a copy of the controller (and request)

You can also simply copy the controller code to a new one and use it to register customers in the version v2. This way we will get rid of checks and isolate the code of a specific version in a specific class.

<?php

namespace App\Http\Controllers;

use App\Jobs\SendRegistrationCode;
use App\Models\Customer;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;

class CustomerRegistrationApiControllerV2
{
    public function store(Request $request): JsonResponse
    {
        $existedCustomer = Customer::where('phone', $request->input('phone.number'))->first();

        if (!is_null($existedCustomer)) {
            throw new \InvalidArgumentException('Покупатель уже зарегистрирован!');
        }

        $customer = Customer::create([
            'phone' => $request->input('phone.number'),
            'phone_country' => $request->input('phone.country') ?? 'RU',
            'password' => bcrypt($request->input('password')),
            'name' => $request->input('first_name'),
            'surname' => $request->input('last_name'),
        ]);

        dispatch(new SendRegistrationCode($customer));

        return response()->json([
            'phone' => $customer->phone,
        ]);
    }
}

The code is again pleasant to read, no need to branch when adding a new version – just make another copy.

But another problem may arise here. The new version may add new optional fields that are unlikely to break backward compatibility for the previous version, so they can be copied into v1.

Now you need to check whether new fields have been ported to old versions; any changes in the new version (in an amicable way) should also be ported to the previous version in order to maintain the same operating logic.

The third way is to use interfaces

This is the way I want to share.

Most likely you know that service container Laravel allows link the interface and implementationafter which it is enough to request the desired interface, and not a specific class.

Did you know this works with queries too? If you have a request class (inherited from Illuminate\Http\FormRequest) with validation and authorization rules and it implements an interface, you can inject this interface into a controller method and Laravel will perform authorization checks and data validation just as if you had implemented a request class.

This feature allows us to make two different requests for each version of the API, describe our validation rules in them, and implement a common interface with data getters. Then request this interface in a controller method and remove all version checks (and, accordingly, do not copy controllers).

In addition to this, you can create an API response transformer interface and return the required structure from version-specific implementations.

<?php

namespace App\Http\Controllers;

use App\Interfaces\Requests\RegisterCustomerRequestInterface;
use App\Interfaces\Transformers\CustomerRegisteredTransformerInterface;
use App\Jobs\SendRegistrationCode;
use App\Models\Customer;
use Illuminate\Http\JsonResponse;

class CustomerRegistrationApiController
{
    public function store(
        RegisterCustomerRequestInterface $request,
        CustomerRegisteredTransformerInterface $transformer,
    ): JsonResponse {
        $existedCustomer = Customer::where('phone', $request->getPhoneNumber())->first();

        if (!is_null($existedCustomer)) {
            throw new \InvalidArgumentException('Покупатель уже зарегистрирован!');
        }

        $customer = Customer::create([
            'phone' => $request->getPhoneNumber(),
            'phone_country' => $request->getPhoneCountry(),
            'password' => bcrypt($request->getCustomerPassword()),
            'name' => $request->getFirstName(),
            'surname' => $request->getLastName(),
        ]);

        dispatch(new SendRegistrationCode($customer));

        return response()->json($transformer->toArray($customer));
    }
}

With this method you can avoid the inconvenience of previous methods and simplify the process of adding new versions:

  1. The controller no longer needs to check which version is currently in use;

  2. Validation does not occur in one request, but in a version-specific API. The same applies to transformers – the structure of the response may differ radically from version to version, but the controller will always give the correct response;

  3. The buyer registration code is executed in one place – if you add new optional fields, you just need to add new methods to the interface and implement them inside each request – no need to copy the code between different controllers;

  4. And if you want to add a new field for just one version, implementations of older versions can return default values ​​(for example, null).

As the project develops, the controller code can be moved into a separate handler class and, if necessary, the handler class can also be versioned (but then the problem of copying code between versions of handlers may return).

Implementation in Laravel

Now let's see how exactly to add such a versioning system to Laravel. The main idea is that all API routes (which must be versioned) need to have a version identifier attached to them. Then, for each possible version, you need to map the required interfaces and their implementations. Then all that remains is to use interfaces inside controllers or API request handlers.

Version ID

The simplest option is to make custom middleware that will add an instance of the current version to the application container service.

<?php

namespace App\Http\Middleware;

use Illuminate\Http\Request;

class SetApiVersion
{
    public function handle(Request $request, callable $next, string $version)
    {
        // Параметр `$version` будет передаваться из файлов роутов.
        // С помощью app()->instance() мы добавляем в сервис контейнер приложения
        // идентификатор текущей версии. Вы сможете запросить его в любом месте,
        // которое будет выполняться после текущего middleware.

        app()->instance('api.version', $version);

        // Получить идентификатор версии теперь можно с помощью app()->get('api.version').

        // Версия API установлена, отправляем запрос дальше.

        return $next($request);
    }
}

Now this middleware can be used in route files:

<?php

use App\Http\Middleware\SetApiVersion;

Route::group(['prefix' => '/v1', 'middleware' => [SetApiVersion::class.':v1']], function () {
    // Все контроллеры, описанные в этой группе, получат идентификатор версии `v1`.
});

Route::group(['prefix' => '/v2', 'middleware' => [SetApiVersion::class.':v2']], function () {
    // Все контроллеры, описанные в этой группе, получат идентификатор версии `v2`.
});

Registering Interfaces and Implementations

To register interfaces and implementations, let's create a new one service provider.

Within it, you will need to map interfaces, implementations, and version IDs. This can be done either through the configuration file or directly within the provider. To simplify the example, we will do this inside the provider.

<?php

namespace App\Providers;

use App\Interfaces\Requests\RegisterCustomerRequestInterface;
use App\Interfaces\Transformers\CustomerRegisteredTransformerInterface;
use App\Http\Requests\V1\RegisterCustomerRequest as RegisterCustomerRequestV1;
use App\Http\Requests\V2\RegisterCustomerRequest as RegisterCustomerRequestV2;
use App\Http\Transformers\V1\CustomerRegisteredTransformer as CustomerRegisteredTransformerV1;
use App\Http\Transformers\V2\CustomerRegisteredTransformer as CustomerRegisteredTransformerV2;
use Illuminate\Support\ServiceProvider;

class ApiVersioningServiceProvider extends ServiceProvider
{
    /**
     * Для каждого интерфейса создадим список реализаций, где ключ – идентификатор версии,
     * а значение – полное имя класса реализации.
     */
    protected array $versions = [
        RegisterCustomerRequestInterface::class => [
            'v1' => RegisterCustomerRequestV1::class,
            'v2' => RegisterCustomerRequestV2::class,
        ],
        CustomerRegisteredTransformerInterface::class => [
            'v1' => CustomerRegisteredTransformerV1::class,
            'v2' => CustomerRegisteredTransformerV2::class,
        ],
    ];

    public function register(): void
    {
        // Зарегистрируем резолвер для каждого интерфейса.

        $abstractions = array_keys($this->versions);

        foreach ($abstractions as $abstract) {
            $this->app->bind($abstract, function () use ($abstract) {
                // Важно: запрашивайте реализацию внутри замыкания – так вы будете уверены,
                // что все необходимые сервисы были зарегистрированы в контейнере,
                // и текущая версия API доступна.

                return $this->getImplementation($abstract);
            });
        }
    }

    protected function getImplementation(string $abstract): mixed
    {
        // Идентификатор API-версии, который был добавлен в контейнер внутри middleware SetApiVersion.
        $version = $this->app->get('api.version');

        // Убедимся, что интерфейс и реализация для текущей версии заданы.
        if (!isset($this->versions[$abstract])) {
            throw new \RuntimeException('The ['.$abstract.'] binding does not exist in versions list!');
        } elseif (!isset($this->versions[$abstract][$version])) {
            throw new \RuntimeException('The ['.$abstract.'] binding does not have an implementation for version ['.$version.']!');
        }

        // Реализация интерфейса для текущей версии существует,
        // создадим инстанс и вернём его.

        return $this->app->make($this->versions[$abstract][$version]);
    }
}

Don't forget to register the new provider in the application's list of providers. Now the interfaces will be available inside the controllers and depending on the current version you will receive the necessary implementation of the interface.

Ready solutions

If you are interested in a ready-made solution for this approach, you can try using my library laniakea/laniakea. API versioning it is made exactly according to this principle and has already been tested on several personal projects.

In addition to API versioning, it can help you create API resources or, for example, implement support model settings.

Read how exactly is the library organized? look at the demo applicationwhich uses all the functionality of the library.

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *