Jak wystawiać własne API w Laravelu: struktura, walidacja, zasoby i wersjonowanie

0
42
Rate this post

Z tego tekstu dowiesz się...

Kontekst i cele: po co wystawiać własne API w Laravelu

Typowe scenariusze: SPA, aplikacje mobilne i integracje B2B

API w Laravelu pojawia się zwykle w trzech sytuacjach: gdy frontem jest aplikacja SPA (React, Vue, Angular), gdy powstaje aplikacja mobilna (Android, iOS, Flutter) lub gdy trzeba zintegrować system z zewnętrznymi partnerami (B2B, integracje wewnętrzne, mikrousługi). W każdej z tych sytuacji backend przestaje renderować widoki Blade, a zaczyna pełnić rolę dostawcy danych – stabilnego, dobrze opisanego kontraktu HTTP/JSON.

W przypadku SPA frontend chce szybkich, przewidywalnych odpowiedzi JSON, dobrze zaprojektowanych endpointów i jasnej obsługi błędów. W mobile backend musi być odporny na gorsze łącze, potrzebuje paginacji, filtrowania i rozsądnego ograniczania pola danych. Integracje B2B często wymagają dodatkowego nacisku na versioning, kompatybilność wsteczną, dokumentację i precyzyjne kody błędów, bo po drugiej stronie nie ma naszych programistów, którzy „zajrzą w kod”.

Laravel daje kompletny zestaw narzędzi do budowy takiego API: od routingu, przez walidację requestów i API Resources, aż po obsługę wyjątków i autoryzację. Kluczem nie jest sama technologia, ale uporządkowana architektura: jasna struktura katalogów, małe kontrolery, spójna walidacja oraz przemyślane wersjonowanie, tak aby projekt był rozwijalny przez lata.

Różnice między „Blade + kontrolery” a podejściem API-first

Klasyczna aplikacja Laravelowa typu „Blade + kontrolery” generuje HTML, używa sesji, redirectów i flash message’y. Kontroler może po walidacji zapisać coś w bazie i wykonać redirect()->back() z komunikatem w sesji. W API-first nie ma widoków i redirectów – każdy endpoint odpowiada czystym JSON-em, a na błędy odsyła ustandaryzowany format z kodem HTTP.

Różni się także kontekst użytkownika. W aplikacji webowej często działa pełna sesja, CSRF, middleware „web”, a autoryzacja może opierać się na klasycznym logowaniu przez formularz. W API middleware „web” najczęściej zostaje pominięte, a wchodzi w grę tokenizacja (Sanctum, Passport, JWT), nagłówki Authorization i brak sesji po stronie serwera. Zmienia się także model komunikacji błędów: zamiast „wróć do formularza i pokaż błędy przy polach” – JSON z listą błędów i kodem 422.

Podejście API-first wymusza wyraźne rozdzielenie warstw: HTTP, domena, persystencja. Kontroler nie może już bezmyślnie łączyć wszystkiego. Musi przejąć odpowiedzialność tylko za to, co HTTP: pobranie requestu, wywołanie właściwego use case’u/serwisu i opakowanie odpowiedzi w Resource. Taki styl kodowania szybko procentuje, gdy rośnie liczba endpointów.

Kiedy wystarcza proste API, a kiedy trzeba planować wersjonowanie

W małych projektach, szczególnie wewnętrznych, prosty układ /api/… bez wersjonowania może być wystarczający. Jeżeli API obsługuje tylko jedną aplikację frontendową rozwijaną przez ten sam zespół, zmiany w kontrakcie można czasem skoordynować od razu po obu stronach. Wtedy wersjonowanie (v1, v2) wygląda na zbędny formalizm.

Sytuacja zmienia się, gdy:

  • API ma być dostępne dla zewnętrznych partnerów lub klientów,
  • ma z niego korzystać kilka różnych aplikacji (np. dwie aplikacje mobilne + panel admina),
  • kontrakt API ma obowiązywać długo, a zespół planuje intensywny rozwój funkcji,
  • w projekcie przewiduje się większe refaktoryzacje modeli i struktur danych.

Wtedy warto od razu postawić na wersjonowanie w strukturze katalogów i tras, nawet jeśli obecnie istnieje tylko v1. Zapobiega to późniejszej, bolesnej migracji „na żywym organizmie”. Wystarczy narzucić prostą konwencję: AppHttpControllersApiV1…, AppHttpResourcesApiV1… oraz prefix route’ów /api/v1. Druga wersja API nie zaburzy działania dotychczasowych klientów.

Wpływ API na architekturę całego projektu

Decyzja o API-first zmienia sposób, w jaki buduje się architekturę aplikacji. Zamiast jednego rozbudowanego folderu HttpControllers pojawiają się wyraźnie wydzielone moduły domenowe (User, Order, Product, Billing), z których każdy ma swoje kontrolery, requesty, zasoby i use case’y. W bardziej rozbudowanych projektach wchodzi w grę modułowa architektura lub lekkie DDD, gdzie każda domena ma osobny pakiet logiki.

Dobry układ katalogów pod API pomaga:

  • odseparować HTTP od logiki domenowej (kontroler ≠ logika biznesowa),
  • łatwo odnaleźć zasoby związane z konkretnym modułem,
  • ułatwić refaktoryzacje (zmiana logiki, ale ten sam kontrakt HTTP),
  • kontrolować wersjonowanie na poziomie zasobów i kontrolerów.

W praktyce wygodnym kompromisem jest: prosta, ale konsekwentna struktura katalogów, kontrolery i zasoby wersjonowane, a logika biznesowa w modułach/service’ach niezależnych od HTTP. Taki układ daje sporą elastyczność bez wchodzenia w nadmiernie skomplikowane schematy architektoniczne.

Zbliżenie ekranu z kolorowym kodem HTML, CSS i JavaScript
Źródło: Pexels | Autor: Саша Алалыкин

Fundamenty architektury API w Laravelu

REST-ish w praktyce zamiast akademickiego REST

Na poziomie teorii REST to zestaw zasad: uniform interface, statelessness, cacheability, layered system itd. W realnych projektach Laravelowych rzadko implementuje się „czysty” REST. Zamiast tego dominuje podejście REST-ish: zasoby projekowane są w przybliżeniu do REST, ale dopuszczalne są odstępstwa, jeśli wspierają czytelność kodu lub biznes.

Przykład: zamiast sztywno trzymać się POST /orders/{order}/cancel jako niestandardowej akcji, część zespołów decyduje się na POST /orders/{order}/status z polem status w body. Inni wolą jednak jawne /cancel, bo jest czytelniejsze dla klienta API. Ważne, aby w obrębie jednego projektu zachować spójność, a nie doskonałość teoretyczną.

Praktyczny REST-ish w Laravelu zwykle oznacza:

  • użycie HTTP verbs w przewidywalny sposób (GET, POST, PUT/PATCH, DELETE),
  • endpointy oparte na rzeczownikach w liczbie mnogiej (users, orders, invoices),
  • jasne, ustandaryzowane kody odpowiedzi HTTP,
  • w miarę możliwości idempotentność operacji modyfikujących (PUT/PATCH).

Nie trzeba za wszelką cenę wymuszać „jednego słusznego REST-u”. Lepiej zainwestować czas w spójny kontrakt, dobrą walidację i zrozumiałe zasoby niż w dyskusję, czy /search jest „wystarczająco REST-owe”.

Myślenie resource-oriented: encje i operacje na nich

Dobrze zaprojektowane API w Laravelu opiera się na myśleniu o zasobach (resources). Zasób to często encja domenowa (User, Post, Order), ale bywa też agregatem kilku encji (Dashboard, Report). Laravel ma wbudowany system API Resources, który naturalnie wspiera takie podejście: każdy zasób ma swoją klasę Resource oraz potencjalnie Resource Collection.

Projektując zasoby, warto zadać sobie pytanie: „Co jest jednostką biznesową, którą aplikacja frontowa widzi jako całość?”. Dla bloga to będzie User, Post, Comment. Dla sklepu: Product, Category, CartItem, Order, Payment. Następnie:

  • dla każdej encji planuje się podstawowe operacje CRUD (create, read, update, delete),
  • dokumentuje się operacje dodatkowe (aktywacja, publikacja, zmiana statusu),
  • dla list przewiduje się paginację i filtrację.

API Resources w Laravelu działają tu jako kontrakt pomiędzy HTTP a domeną. Model może się zmieniać (np. dojdą nowe kolumny), ale Resource kontroluje, co jest ujawniane światu zewnętrznemu. To fundament stabilnego API, szczególnie po dodaniu wersjonowania zasobów.

Spójne nazewnictwo endpointów, metody HTTP i kody odpowiedzi

Spójność jest ważniejsza niż pojedyncze „perfekcyjne” endpointy. Klient API ma mieć poczucie, że raz zrozumiane zasady działają w całym systemie. Dobrą praktyką jest:

  • GET /users – lista zasobów (z paginacją),
  • GET /users/{id} – szczegóły zasobu,
  • POST /users – utworzenie nowego zasobu (kod 201),
  • PUT/PATCH /users/{id} – aktualizacja,
  • DELETE /users/{id} – usunięcie (kod 204 lub 200 z body).

Akcje niestandardowe (np. reset hasła, wysłanie zaproszenia) można projektować jako:

  • POST /users/{id}/invite,
  • POST /users/{id}/reset-password,
  • POST /orders/{order}/cancel.

Kody odpowiedzi HTTP także powinny być przewidywalne:

  • 200 – poprawna odpowiedź z danymi,
  • 201 – utworzono nowy zasób,
  • 204 – sukces, ale brak treści (np. usunięcie),
  • 400 – niepoprawny request (rzadziej w Laravelu, większość walidacji kończy się 422),
  • 401 – brak autoryzacji (niezalogowany),
  • 403 – brak uprawnień (zalogowany, ale zabronione),
  • 404 – zasób nie istnieje,
  • 422 – błędy walidacji danych wejściowych,
  • 500 – błąd serwera.

Warto zaprojektować wspólny format odpowiedzi dla błędów i sukcesów, aby front nie musiał zgadywać struktury. W dalszych sekcjach pojawi się konkretny schemat JSON i sposób integracji z Handlerem wyjątków.

Kiedy wchodzić w DDD lub modułową architekturę

Nie każdy projekt API w Laravelu potrzebuje Domain-Driven Design i rozbudowanej warstwy modułów. Nadmiarowe skomplikowanie na start spowalnia dostarczanie funkcjonalności. Zdrowy próg wejścia na „wyższy poziom architektury” to:

  • rozwinięta logika biznesowa (dużo reguł, nie tylko CRUD),
  • wiele powiązanych modułów domenowych (np. billing, subskrypcje, raporty, powiadomienia),
  • plany długoterminowego rozwoju przez kilka zespołów jednocześnie,
  • realne ryzyko, że kontrolery i modele „eksplodują” od ilości kodu.

Przed wejściem w pełne DDD można wdrożyć lżejsze podejście:

  • wydzielić folder AppServices lub AppUseCases dla przypadków użycia,
  • trzymać logikę biznesową poza kontrolerami i poza API Resources,
  • użyć prostego podziału modułowego: App/Domain/User, App/Domain/Order itd.,
  • traktować modele Eloquent głównie jako warstwę ORM, nie centralne „bogate modele”.

Z biegiem czasu, gdy API się rozrośnie, przejście na pełniejszy model DDD będzie łatwiejsze, bo warstwa HTTP pozostanie cienka i odseparowana.

Struktura projektu pod API – katalogi, konwencje, porządek

Oddzielenie warstwy HTTP od warstwy domeny

Laravel domyślnie wrzuca większość rzeczy do AppHttp. W projekcie API warto to wykorzystać i doprecyzować: warstwa HTTP to tylko kontrolery, Form Requesty, Resources i middleware. Logika biznesowa, reguły domenowe, integracje z innymi systemami – wszystko poza AppHttp.

Przykładowy układ:

  • app/Http/Controllers/Api/V1 – kontrolery API v1,
  • app/Http/Requests/Api/V1 – FormRequesty dla v1,
  • app/Http/Resources/Api/V1 – API Resources dla v1,
  • app/Domain/User – logika domenowa użytkowników (serwisy, encje, reguły),
  • app/Domain/Order – logika zamówień.

Taki podział zapewnia, że zmiany w domenie (np. nowa reguła naliczania rabatu) nie wymagają modyfikacji wszystkich kontrolerów. Kontroler wywołuje po prostu odpowiedni use case, a szczegóły implementacji siedzą w AppDomain lub AppServices.

Organizacja kontrolerów API według wersji i modułów

Dobrą praktyką jest osobny namespace dla każdej wersji API, z podziałem na moduły. Przykładowo:

  • AppHttpControllersApiV1AuthLoginController,
  • AppHttpControllersApiV1UserUserController,
  • AppHttpControllersApiV1OrderOrderController,
  • AppHttpControllersApiV1ProductProductController.

W ramach jednego modułu można stosować dodatkowe kontrolery do akcji specjalnych, np. OrderStatusController, jeśli standardowy CRUD nie wystarcza. Ważne, aby kontroler miał jedną odpowiedzialność: np. UserController do zarządzania użytkownikami, a nie „UserOrderPaymentController” łączący trzy domeny na raz.

Gdy pojawi się V2, powstanie równoległy namespace: AppHttpControllersApiV2…. Pozwoli to na stopniowe przepinanie klientów na nową wersję bez ingerencji w istniejące endpointy v1.

Podobny układ katalogów dobrze przenieść na trasy oraz pozostałe elementy warstwy HTTP. Trasy dla V1 lądują w osobnym pliku (np. routes/api_v1.php), a w RouteServiceProvider spinasz je z odpowiednim prefixem i przestrzenią nazw. Dzięki temu łatwo wyłączyć całą wersję, podmienić middleware lub wprowadzić nowe limity rate limiting tylko dla konkretnej gałęzi API, bez ryzyka, że coś „przy okazji” dotknie innych klientów.

Prosty schemat, który sprawdza się w średnich projektach:

  • routes/api_v1.php, routes/api_v2.php – trasy per wersja,
  • middleware grupowe typu api.v1, api.v2 z osobnymi limitami i logowaniem,
  • spójne nazewnictwo nazw tras (np. v1.users.index, v1.orders.store).

Przy takim podejściu kontroler nie „wie”, że obsługuje konkretną wersję – to kwestia namespace’u i routingu. Migracja wybranych endpointów do V2 może wtedy polegać na skopiowaniu trasy do nowego pliku i wskazaniu innego kontrolera lub innego Resource’a, bez ruszania starej implementacji.

Porządek w FormRequestach i API Resources

FormRequesty i Resources dobrze dzielić dokładnie tak samo jak kontrolery: według wersji i modułów. W praktyce oznacza to struktury typu:

  • app/Http/Requests/Api/V1/User/StoreUserRequest.php,
  • app/Http/Requests/Api/V1/User/UpdateUserRequest.php,
  • app/Http/Resources/Api/V1/User/UserResource.php,
  • app/Http/Resources/Api/V1/User/UserCollection.php.

Dzięki temu w kontrolerze nie szukasz po całej aplikacji, tylko pracujesz w jednym module. Gdy pojawi się V2, tworzysz nowe FormRequesty i Resources w analogicznym układzie i możesz spokojnie rozjechać się z polami, regułami walidacji czy kształtem odpowiedzi JSON – bez ryzyka, że coś złamie zgodność w starej wersji.

Dobrym nawykiem jest, żeby FormRequest zawierał wyłącznie walidację i autoryzację, a nie „magiczne” metody typu toDto() z logiką domenową. Podobnie Resource powinien odpowiadać tylko za transformację danych do JSON. Wtedy wymiana warstwy domeny (np. przejście z Eloquent na inne źródło danych) nie wymusza refaktoringu całej walidacji ani JSON-ów.

Gdzie trzymać logikę biznesową w projekcie API

Wyrzucenie logiki biznesowej z kontrolerów to dopiero pierwszy krok. W większości projektów wygodnie sprawdza się prosty podział na przypadki użycia (use cases) lub serwisy domenowe. Przykładowo:

  • app/Domain/User/UseCases/CreateUser.php,
  • app/Domain/User/UseCases/UpdateUser.php,
  • app/Domain/Order/UseCases/PlaceOrder.php.

Taka klasa CreateUser zawiera jedną publiczną metodę (np. handle()) i kompletny proces: walidacja biznesowa, operacje na repozytoriach, integracje z zewnętrznymi systemami. Kontroler robi tylko dwie rzeczy: uruchamia przypadek użycia i opakowuje wynik w odpowiedni Resource. Dzięki temu to, co się dzieje „w środku”, można testować niezależnie od HTTP i łatwo wykorzystywać także w innych interfejsach (np. w konsoli lub jobach).

Tak uporządkowana struktura projektu pod API – osobne katalogi na wersje, moduły, FormRequesty, Resources i logikę domenową – ułatwia dalsze decyzje: wprowadzenie nowych wersji, dodawanie kolejnych integracji czy migrację do bardziej rozbudowanego DDD. Kluczowe jest, żeby od początku warstwa HTTP była cienka i przewidywalna, a cała „inteligencja” systemu siedziała w miejscach, które można spokojnie rozwijać i wymieniać bez naruszania kontraktu API.

Dwóch programistów analizuje kod API na dużym monitorze w biurze
Źródło: Pexels | Autor: Mikhail Nilov

Projektowanie i rejestrowanie tras API w Laravelu

Oddzielne pliki tras dla API i wersji

Dobrze zacząć od rozdzielenia tras webowych od API, a w API – od razu według wersji. Standardowy routes/api.php można zostawić jako cienki „router główny” lub nie używać go wcale, a w RouteServiceProvider wpiąć osobne pliki:

Przeczytaj także:  Jak połączyć swoją aplikację z Google Maps API?

  • routes/api_v1.php,
  • routes/api_v2.php.

Przykładowa konfiguracja w app/Providers/RouteServiceProvider.php:


public function boot(): void
{
    $this->routes(function () {
        Route::middleware('api')
            ->prefix('api/v1')
            ->as('v1.')
            ->group(base_path('routes/api_v1.php'));

        Route::middleware(['api', 'throttle:api_v2'])
            ->prefix('api/v2')
            ->as('v2.')
            ->group(base_path('routes/api_v2.php'));
    });
}

Nazwy tras z prefixem v1. i v2. od razu wskazują wersję. Przy refaktorach i wyszukiwaniu route’ów w IDE to spore ułatwienie.

Grupowanie tras według modułów

W pliku api_v1.php trasy nie muszą być jednolitą listą. Dobrze je zgrupować według modułów i wspólnych middleware. Na przykład:


use AppHttpControllersApiV1AuthLoginController;
use AppHttpControllersApiV1UserUserController;
use IlluminateSupportFacadesRoute;

Route::prefix('auth')
    ->as('auth.')
    ->group(function () {
        Route::post('login', [LoginController::class, 'login'])
            ->name('login');
        Route::post('logout', [LoginController::class, 'logout'])
            ->middleware('auth:sanctum')
            ->name('logout');
    });

Route::middleware('auth:sanctum')
    ->prefix('users')
    ->as('users.')
    ->group(function () {
        Route::get('/', [UserController::class, 'index'])->name('index');
        Route::post('/', [UserController::class, 'store'])->name('store');
        Route::get('{user}', [UserController::class, 'show'])->name('show');
        Route::put('{user}', [UserController::class, 'update'])->name('update');
        Route::delete('{user}', [UserController::class, 'destroy'])->name('destroy');
    });

Każda grupa ma swojego prefixa, alias nazwy i zestaw middleware. Łatwo podbić limity rate limiting tylko dla auth.*, albo dorzucić logging wyłącznie na orders.*.

RESTowe konwencje tras i kiedy je łamać

W typowym API większość tras spokojnie mieści się w klasycznym CRUD. W Laravelu:

  • index – lista,
  • store – utworzenie,
  • show – szczegóły,
  • update – aktualizacja,
  • destroy – usunięcie.

Szybko można zarejestrować je pojedynczą instrukcją:


Route::apiResource('orders', OrderController::class);

apiResource pomija widoki (create, edit) i pasuje do typowego REST-owego API. Gdy pojawia się nietypowa akcja (np. zmiana statusu zamówienia), można:

  • dodać metodę w tym samym kontrolerze i osobną trasę, lub
  • wydzielić dedykowany kontroler do akcji „commandowych”.

Przykład z dodatkowymi trasami:


Route::apiResource('orders', OrderController::class);

Route::post('orders/{order}/cancel', [OrderStatusController::class, 'cancel'])
    ->name('orders.cancel');

Route::post('orders/{order}/pay', [OrderPaymentController::class, 'pay'])
    ->name('orders.pay');

W praktyce lepiej zrezygnować z dziwnych czasowników w nazwach zasobów (/doOrder) i jasno nazywać operacje, które wychodzą poza standardowy CRUD.

Route model binding i serializery

Route model binding oszczędza ręczne wyszukiwanie modeli na podstawie ID w każdej metodzie. W API dobrze jednak przemyśleć, co dokładnie jest „kluczem publicznym”. Czasem nie chcesz wystawiać ID, tylko np. UUID.

W modelu można przestawić klucz routingu:


class User extends Model
{
    public function getRouteKeyName(): string
    {
        return 'uuid';
    }
}

Trasa:


Route::get('users/{user}', [UserController::class, 'show']);

Metoda w kontrolerze dostaje już User $user, wyszukany po uuid. Jeśli API ma inne wymagania (np. wyszukiwanie po slugach), można też skorzystać z zagnieżdżonego bindowania czy własnych resolverów w RouteServiceProvider.

Kontrolery API – lekka warstwa HTTP zamiast „bogów aplikacji”

Minimalny zakres odpowiedzialności kontrolera

Kontroler API powinien robić trzy rzeczy:

  1. Odebrać i zinterpretować request (FormRequest, parametry, user).
  2. Przekazać dane do warstwy domeny / use case’u.
  3. Zwrócić wynik jako Resource lub pustą odpowiedź z kodem statusu.

Bez walidacji biznesowej, bez tworzenia obiektów domenowych „na piechotę” i bez logiki typu „jeśli user ma rolę X to zrób Y, a jeśli ma Z to…”. To należy przenieść niżej – do serwisów lub use case’ów.

Przykładowy kontroler „cienkiej” warstwy HTTP

Prosty przykład kontrolera użytkowników:


namespace AppHttpControllersApiV1User;

use AppDomainUserUseCasesCreateUser;
use AppDomainUserUseCasesUpdateUser;
use AppHttpControllersController;
use AppHttpRequestsApiV1UserStoreUserRequest;
use AppHttpRequestsApiV1UserUpdateUserRequest;
use AppHttpResourcesApiV1UserUserResource;
use AppModelsUser;

class UserController extends Controller
{
    public function index()
    {
        $users = User::query()
            ->latest()
            ->paginate(20);

        return UserResource::collection($users);
    }

    public function store(StoreUserRequest $request, CreateUser $createUser)
    {
        $user = $createUser->handle($request->validated());

        return (new UserResource($user))
            ->response()
            ->setStatusCode(201);
    }

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

    public function update(UpdateUserRequest $request, User $user, UpdateUser $updateUser)
    {
        $user = $updateUser->handle($user, $request->validated());

        return new UserResource($user);
    }

    public function destroy(User $user)
    {
        $user->delete();

        return response()->noContent();
    }
}

Efekt: kontroler jest czytelny, łatwo go przejrzeć, a testy jednostkowe skupiają się na przypadkach użycia. Kontroler można przetestować kilkoma testami integracyjnymi, a cała „ciemna magia” siedzi w CreateUser, UpdateUser i ewentualnych serwisach.

Użycie dependency injection i kontener IoC

Laravelowy kontener robi za fabrykę obiektów. W kontrolerze można spokojnie wstrzykiwać use case’y po interfejsach, co ułatwia późniejszą podmianę implementacji. W projekcie API ma to znaczenie, gdy:

  • trzeba zmienić źródło danych (np. z bazy lokalnej na zewnętrzny serwis),
  • powstaje druga implementacja tej samej logiki dla innego klienta (np. panel admina vs API partnerów).

Binding w AppServiceProvider lub własnym providerze:


$this->app->bind(
    AppDomainUserContractsCreatesUsers::class,
    AppDomainUserUseCasesCreateUser::class
);

W kontrolerze:


public function store(StoreUserRequest $request, CreatesUsers $creator)
{
    $user = $creator->handle($request->validated());

    return (new UserResource($user))
        ->response()
        ->setStatusCode(201);
}

Modyfikacja logiki tworzenia użytkownika nie wymaga dotykania kontrolera ani tras – wystarczy podmienić binding.

Gdzie przeciąć kontroler, gdy zaczyna puchnąć

Jeśli kontroler zaczyna mieć:

  • więcej niż kilka metod,
  • wiele ścieżek warunkowych w środku metod,
  • częste if ($request->has('...')) modyfikujące zachowanie,
  • mieszankę endpointów publicznych i „admin-only”.

To sygnał, że warto:

  • wydzielić dodatkowe kontrolery (np. AdminUserController),
  • przenieść zagnieżdżone warunki do use case’ów lub polityk autoryzacji,
  • wprowadzić dedykowany kontroler per „typ klienta” (mobile, partner, admin), trzymając wspólną domenę pod spodem.

W jednym z projektów komercyjnych rozdzielenie OrderController na trzy kontrolery (publiczny, panel klienta, panel admina) zredukowało konflikty w MR-ach o połowę i uprościło logikę walidacji.

Dwóch programistów przy laptopach omawia kod API w biurze
Źródło: Pexels | Autor: olia danilevich

Walidacja requestów – Form Requests, reguły, komunikaty błędów

FormRequest jako standard walidacji w API

W API ręczne wywoływanie $request->validate() w każdej metodzie szybko staje się powtarzalne. Wygodniej od razu oprzeć się na FormRequestach. Przykład dla tworzenia użytkownika:


namespace AppHttpRequestsApiV1User;

use IlluminateFoundationHttpFormRequest;

class StoreUserRequest extends FormRequest
{
    public function authorize(): bool
    {
        return $this->user()?->can('create', User::class) ?? false;
    }

    public function rules(): array
    {
        return [
            'name' => ['required', 'string', 'max:255'],
            'email' => ['required', 'email', 'max:255', 'unique:users,email'],
            'password' => ['required', 'string', 'min:8'],
        ];
    }
}

Metoda authorize() jest pierwszą linią obrony. Logika uprawnień nie ląduje w kontrolerze – tam zostaje tylko „happy path” dla poprawnie zweryfikowanych użytkowników.

Walidacja specyficzna dla wersji API

Dzięki wersjonowaniu w AppHttpRequestsApiV1 i AppHttpRequestsApiV2 można swobodnie modyfikować reguły. Przykładowo:

  • w v1 phone było opcjonalne,
  • w v2 staje się wymagane i musi przejść dodatkową regułę (np. format E.164).

Nie trzeba kombinować z warunkami typu if (app()->environment('v2')). Każda wersja ma własny FormRequest i własne reguły:


// V1
'phone' => ['nullable', 'string', 'max:20'],

// V2
'phone' => ['required', 'string', new PhoneNumberRule()],

Klient v1 nadal może wysyłać dane po staremu, a nowi klienci v2 od razu dostają ostrzejszą walidację.

Własne reguły walidacji

Gdy standardowe reguły Laravelowe nie wystarczą, dobrze wydzielić własne reguły jako klasy. W API często przydają się:

  • walidacja identyfikatorów zewnętrznych systemów,
  • sprawdzanie, czy dane pole jest dozwolone w current planie/subskrypcji,
  • walidacja struktury zagnieżdżonych JSON-ów.

Przykładowa reguła:


use IlluminateContractsValidationRule;

class ValidCurrencyCode implements Rule
{
    public function passes($attribute, $value): bool
    {
        return in_array($value, ['PLN', 'EUR', 'USD'], true);
    }

    public function message(): string
    {
        return 'Pole :attribute musi zawierać poprawny kod waluty.';
    }
}

Użycie w FormRequeście:


'currency' => ['required', 'string', new ValidCurrencyCode()],

Takie reguły można współdzielić między różnymi wersjami API, o ile kontrakt się nie zmienia. Gdy v2 ma inne wymagania, nic nie stoi na przeszkodzie, żeby pojawiła się osobna reguła ValidCurrencyCodeV2 lub bardziej ogólna konfiguracja walidacji.

Spójny format błędów walidacji

Domyślnie Laravel dla API zwraca błąd 422 z polem errors, ale struktura bywa niespójna między różnymi typami wyjątków. Dobrą praktyką jest narzucenie jednolitego formatu, np.:


{
  "success": false,
  "message": "Podane dane są nieprawidłowe.",
  "errors": {
    "email": ["Pole email jest wymagane."],
    "password": ["Hasło musi mieć co najmniej 8 znaków."]
  }
}

FormRequest wystarczy ustawić tak, aby korzystał z tego schematu. Można to zrobić, nadpisując failedValidation:


use IlluminateContractsValidationValidator;
use IlluminateHttpExceptionsHttpResponseException;

abstract class ApiFormRequest extends FormRequest
{
    protected function failedValidation(Validator $validator)
    {
        $response = response()->json([
            'success' => false,
            'message' => 'Podane dane są nieprawidłowe.',
            'errors'  => $validator->errors()->toArray(),
        ], 422);

        throw new HttpResponseException($response);
    }
}

Konkretne requesty dziedziczą wtedy po klasie bazowej:


class StoreUserRequest extends ApiFormRequest
{
    // tylko authorize() i rules()
}

Dzięki temu każdy błąd walidacji z całego API ma jednolity kształt i da się go ogarnąć jedną ścieżką po stronie klienta (np. globalny interceptor w aplikacji mobilnej). W logach również widać od razu pełny zestaw błędów walidacji, a nie losową mieszankę formatów.

Jeżeli chcesz dopasować komunikaty do konkretnego języka lub klienta, nadpisz w FormRequeście metodę messages() albo skorzystaj z plików tłumaczeń. Dobrze działa podejście, w którym nazwy pól i komunikaty są stabilne w ramach wersji API, a tłumaczenia zmieniają się tylko w warstwie prezentacji. Klient może wtedy bazować na kluczach pól, a tekst błędu traktować bardziej jako opis dla człowieka.

Czasem przydaje się też inny kod HTTP dla błędów walidacji w specyficznych przypadkach (np. 409 dla konfliktów). Można to rozwiązać w osobnych wyjątkach domenowych rzucanych z use case’ów albo w customowych regułach walidacji. Ważne, żeby nadal trzymać się jednego schematu odpowiedzi JSON, tak aby konsument API nie musiał obsługiwać pięciu różnych formatów błędów.

Zasoby API w Laravelu: Resource i Resource Collection

Surowe modele Eloquent rzadko nadają się do bezpośredniego wystawiania po HTTP. W API przydaje się stabilna warstwa pośrednia, która kontroluje kształt odpowiedzi. W Laravelu tę rolę pełnią JsonResource i ResourceCollection. Pozwalają:

  • ukryć pola techniczne modelu (np. password, remember_token, znaczniki soft delete),
  • zmienić nazwy pól pod klienta (np. created_atcreatedAt),
  • dodawać wyliczane atrybuty i linki (HATEOAS, adresy do kolejnych zasobów),
  • utrzymać zgodność wsteczną między wersjami API mimo ewolucji modeli.

Przykładowy zasób użytkownika:


namespace AppHttpResourcesApiV1;

use IlluminateHttpResourcesJsonJsonResource;

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

W kontrolerze wystarczy zwrócić new UserResource($user) zamiast modelu. Dzięki temu zmiana struktury tabeli czy dodanie nowych pól nie rozbija istniejących klientów – dopóki toArray() pozostaje zgodne z kontraktem API.

Dla kolekcji lepiej używać dedykowanej klasy zamiast UserResource::collection(), zwłaszcza gdy chcemy dodać metadane paginacji lub globalne pola:


use IlluminateHttpResourcesJsonResourceCollection;

class UserCollection extends ResourceCollection
{
    public function toArray($request): array
    {
        return [
            'data' => $this->collection->transform(function ($user) {
                return (new UserResource($user))->toArray($request);
            }),
            'meta' => [
                'total' => $this->resource->total(),
                'per_page' => $this->resource->perPage(),
                'current_page' => $this->resource->currentPage(),
            ],

'links' => [
                'self' => $request->fullUrl(),
            ],
        ];
    }
}

Takie podejście ułatwia dodawanie kolejnych pól meta (np. filtrów użytych w zapytaniu) bez grzebania w każdym kontrolerze. Kontroler zwraca po prostu new UserCollection($paginator), a logika formatu odpowiedzi jest skupiona w jednym miejscu.

W większych projektach przydaje się jasny podział przestrzeni nazw dla zasobów, np. AppHttpResourcesApiV1, ApiV2 itd. Każda wersja ma wtedy swój UserResource, nawet jeśli na początku są identyczne. Gdy w v2 zmienisz podpisy pól albo dodasz nowe relacje, nie musisz dotykać v1 – gwarantujesz stabilny kontrakt klientom korzystającym ze starej wersji.

Dobrym nawykiem jest jawne kontrolowanie ładowania relacji używanych w zasobach. Jeśli w UserResource korzystasz z $this->roles czy $this->profile, w kontrolerze dołóż with() lub osobny query object. Unikasz w ten sposób lawiny zapytań N+1, która potrafi zabić wydajność API przy większej liczbie rekordów.

Gdy pojawia się więcej typów odpowiedzi (np. odpowiedzi z błędem biznesowym, komunikaty systemowe, pliki), spójny szablon JSON-ów i wersjonowane zasoby sprawiają, że aplikacja frontendowa może korzystać z jednego zestawu adapterów. Laravel daje wygodne klocki, ale to od dyscypliny w strukturze, walidacji i zasobach zależy, czy API wytrzyma presję kolejnych iteracji i integracji.

Najczęściej zadawane pytania (FAQ)

Po co w ogóle wystawiać własne API w Laravelu?

API w Laravelu przydaje się, gdy backend ma dostarczać dane zamiast generować widoki HTML. Typowe przypadki to SPA (React, Vue, Angular), aplikacje mobilne (Android, iOS, Flutter) oraz integracje B2B z innymi systemami.

API staje się wtedy stabilnym kontraktem HTTP/JSON: frontend lub partnerzy zewnętrzni dostają przewidywalne endpointy, spójne formaty odpowiedzi i błędów, a backend może rozwijać się niezależnie od warstwy prezentacji.

Czym różni się klasyczna aplikacja Laravel (Blade) od podejścia API-first?

W klasycznej aplikacji Laravel kontrolery zwracają widoki Blade, używają sesji, redirectów i komunikatów flash. Błędy walidacji kończą się zwykle redirect()->back() i wyświetleniem błędów w formularzu.

W API-first kontroler zwraca wyłącznie JSON, nie korzysta z sesji ani redirectów. Błędy walidacji to ustandaryzowana odpowiedź JSON z kodem HTTP (najczęściej 422). Autoryzacja opiera się na tokenach (Sanctum, Passport, JWT) i nagłówku Authorization, a nie na klasycznym logowaniu przez formularz z sesją.

Kiedy muszę wersjonować API w Laravelu (v1, v2 itd.)?

Wersjonowanie ma sens, gdy z API korzysta więcej niż jedna aplikacja, gdy udostępniasz je zewnętrznym partnerom albo gdy kontrakt ma być stabilny przez lata przy równoczesnym szybkim rozwoju funkcji. W małych, wewnętrznych projektach, gdzie frontend i backend rozwija ten sam zespół, początkowo można obyć się bez v1, v2.

Jeśli jednak planujesz większe refaktoryzacje modeli, wiele klientów API lub sprzedaż integracji partnerom, wersjonowanie od początku oszczędzi problemów. Przykład konwencji: AppHttpControllersApiV1…, AppHttpResourcesApiV1… i prefix tras /api/v1.

Jak zaprojektować strukturę katalogów w Laravelu pod API?

Dobry start to podział na moduły domenowe zamiast jednego „worka” HttpControllers. Przykład: AppHttpControllersApiV1User, AppHttpControllersApiV1Order itd. Każdy moduł może mieć swoje Requesty, Resource’y i serwisy/use-case’y.

Warstwa HTTP (kontrolery, requesty, zasoby) powinna być cienka i opakowywać logikę domenową, która siedzi w serwisach lub modułach. Dzięki temu da się zmieniać wnętrze aplikacji bez psucia kontraktu API, a refaktoryzacje są mniej bolesne.

Czym są API Resources w Laravelu i po co ich używać?

API Resources to klasy, które definiują, jak model lub kolekcja modeli zamienia się na JSON. Zamiast zwracać „surowy” model, kontroler zwraca np. UserResource::make($user) albo UserResource::collection($users).

Daje to kontrolę nad tym, co wystawiasz światu: możesz ukryć wrażliwe pola, zmienić nazwy atrybutów, dołożyć dane pochodne. Przy wersjonowaniu możesz mieć różne Resource’y dla V1 i V2, nawet jeśli w środku działa ten sam model Eloquent.

REST vs REST-ish w Laravelu – jak bardzo trzeba być „czystym REST-em”?

W praktyce Laravel częściej stosuje podejście REST-ish niż akademicki REST. Ważniejsze jest spójne używanie metod HTTP (GET, POST, PUT/PATCH, DELETE), sensowne nazwy zasobów (users, orders) oraz przewidywalne kody odpowiedzi niż idealne trzymanie się każdej zasady REST.

Przykład: można mieć POST /orders/{order}/cancel jako osobną akcję albo POST /orders/{order}/status ze zmianą statusu w body. Klucz w tym, żeby cały projekt używał jednolitego stylu, a klient API nie musiał się domyślać, jak działa kolejny endpoint.

Jak wygląda obsługa błędów i walidacji w API Laravelowym?

W API używasz Form Requestów lub ręcznej walidacji, ale zamiast redirectu zwracasz JSON. Laravel potrafi automatycznie zwrócić odpowiedź z kodem 422 i listą błędów, jeśli poprawnie skonfigurujesz globalny handler i korzystasz z Form Requestów w kontrolerach API.

Standardem jest trzymanie jednej, spójnej struktury błędów (np. { „message”: „…”, „errors”: { „field”: [„komunikat”] } }) oraz mapowanie wyjątków domenowych na odpowiednie kody HTTP, np. 404 dla brakującego zasobu, 403 dla braku uprawnień, 409 dla konfliktów biznesowych.

Najważniejsze wnioski

  • API w Laravelu staje się kluczowe przy SPA, aplikacjach mobilnych i integracjach B2B – backend przestaje renderować Blade, a staje się stabilnym dostawcą danych w formacie HTTP/JSON.
  • Przy podejściu API-first kontroler ogranicza się do warstwy HTTP (przyjęcie requestu, wywołanie use case’u, zwrócenie Resource), a logika biznesowa ląduje w oddzielnych serwisach/modułach.
  • API-first wymaga innego modelu autoryzacji i obsługi użytkownika: brak sesji i redirectów, zamiast tego tokeny (Sanctum/Passport/JWT), nagłówki Authorization i spójne komunikaty błędów w JSON (np. 422 z listą błędów).
  • Wewnętrzne, małe API może działać bez wersjonowania, ale przy wielu klientach (mobilki, panel, partnerzy B2B) i długim cyklu życia kontraktu lepiej od razu wprowadzić wersje (np. /api/v1, AppHttpControllersApiV1…).
  • Przemyślana struktura katalogów (moduły domenowe typu User/Order/Product z własnymi kontrolerami, requestami i zasobami) ułatwia utrzymanie, refaktoryzację i rozwijanie API bez łamania kontraktu.
  • Laravel daje pełen zestaw narzędzi pod API (routing, walidacja requestów, Resources, obsługa wyjątków, autoryzacja), ale o jakości decyduje konsekwentna architektura, a nie sam framework.
  • Praktyczne podejście REST-ish jest wystarczające: ważniejsza jest spójność w projektowaniu endpointów (np. czy używamy /orders/{order}/cancel, czy status w body) niż idealna zgodność z akademickim REST-em.
Poprzedni artykułJak rozwijać MVP w startupie technologicznym
Następny artykułJak wygląda proces akceleracji startupu
Grzegorz Wysocki

Grzegorz Wysocki to doświadczony specjalista w dziedzinie webmasteringu i rozwoju webowego z ponad 12-letnią praktyką w branży IT. Absolwent Informatyki na Politechnice Wrocławskiej, gdzie zgłębiał tajniki programowania backendowego, szybko wszedł na ścieżkę profesjonalnego developera, pracując przy złożonych systemach dla firm z branży e-commerce i SaaS.

Specjalizuje się w PHP, MySQL, Laravel oraz Vue.js, optymalizując aplikacje pod kątem wydajności, bezpieczeństwa i skalowalności. Grzegorz zrealizował dziesiątki projektów, w tym zaawansowane platformy sklepowe i systemy zarządzania treścią, które obsługują tysiące użytkowników dziennie. Jest autorem cenionych tutoriali i kursów na temat nowoczesnego webmasteringu, pomagając setkom adeptów opanować praktyczne umiejętności w tworzeniu dynamicznych stron.

Aktywny w społeczności open-source, regularnie przyczynia się do repozytoriów na GitHubie i dzieli się wiedzą na meetupach PHP Polska. Jego pasja to integracje AI w webdevie oraz budowanie szybkich, responsywnych interfejsów. Motto Grzegorza: "Dobry kod to nie tylko funkcjonalność – to elegancja i niezawodność".

Na porady-it.pl dostarcza aktualne, sprawdzone porady, budując zaufanie praktycznym podejściem.

Kontakt: grzegorz_wysocki@porady-it.pl (mailto:_wysocki@porady-it.pl)