Увійти Реєстрація
Блог Серії
Кар'єра
Вакансії Компанії
Навчання
Документація Співбесіди Тестування Відео
Екосистема
Пакети Ресурси Проєкти Інструменти Події
Інше
Про нас Реклама

Питання на співбесіді: API у Laravel

Питання з реальних співбесід з відповідями: Laravel і PHP, бази даних, JavaScript і фронтенд, Git, Docker, API, безпека й архітектура. Тими самими темами, що й тести.

14 питань

У свіжому застосунку Laravel файлу routes/api.php немає - його додає команда:

php artisan install:api

Вона встановлює Laravel Sanctum (автентифікація токенами), створює routes/api.php і підключає його в bootstrap/app.php:

->withRouting(
    web: __DIR__.'/../routes/web.php',
    api: __DIR__.'/../routes/api.php',
    // apiPrefix: 'api/v1',
)

Чим маршрути API відрізняються:

web.php api.php
префікс URL немає /api (змінюється через apiPrefix)
група middleware web api
сесія й cookie так ні (stateless)
CSRF-захист так ні
автентифікація сесія токени (auth:sanctum)
// routes/api.php
Route::middleware('auth:sanctum')->group(function () {
    Route::get('/user', fn (Request $request) => $request->user());
    Route::apiResource('posts', PostController::class);
});

apiResource реєструє маршрути ресурсу без create і edit (вони потрібні лише для HTML-форм): index, store, show, update, destroy. Контролер - php artisan make:controller PostController --api --model=Post.

Що варто знати:

  • група api за замовчуванням не обмежує частоту запитів. Обмеження вмикається явно: визначити лімітер RateLimiter::for('api', ...) і підключити $middleware->throttleApi() в bootstrap/app.php (або throttle:api на маршрутах);
  • відповіді-помилки для запитів з Accept: application/json Laravel повертає в JSON. Клієнтам API варто завжди надсилати цей заголовок - інакше помилка валідації може стати редиректом;
  • власний SPA на тому ж домені може ходити в api.php з сесійною автентифікацією Sanctum ($middleware->statefulApi()), без токенів;
  • маршрути з web.php теж можуть віддавати JSON - для внутрішніх запитів Livewire/Inertia-застосунку окремий API часто не потрібен.

Перевірка: php artisan route:list --path=api показує всі маршрути API з middleware.

Докладніше в документації: Laravel: маршрути API

Повернути модель з контролера можна - Laravel серіалізує її в JSON автоматично:

return $user;   // усі атрибути моделі (крім $hidden)

Але так формат відповіді API = структура таблиці. Додали колонку - вона з'явилася в API. Перейменували - зламали клієнтів. Внутрішнє поле (прапорець, службова дата) - уже публічне.

API Resource - окремий шар, що явно описує, як модель виглядає назовні:

php artisan make:resource UserResource
class UserResource extends JsonResource
{
    public function toArray(Request $request): array
    {
        return [
            'id' => $this->id,
            'name' => $this->name,
            'avatar_url' => $this->avatarUrl(),
            'registered_at' => $this->created_at,
            'posts_count' => $this->whenCounted('posts'),
            'email' => $this->when($request->user()?->is($this->resource), $this->email),
        ];
    }
}
return new UserResource($user);
return UserResource::collection($users);
return $user->toResource();          // те саме, коротше

Що дає ресурс:

  • контракт API відокремлено від бази: перейменування колонки змінює лише ресурс, а не відповідь;
  • явний перелік полів: нове поле в таблиці не з'явиться в API випадково;
  • обчислювані поля (URL, форматування) і умовні поля за правами;
  • вкладені ресурси й зв'язки - лише якщо вони завантажені (whenLoaded);
  • обгортка data, пагінація з links і meta - автоматично для колекцій.

Обгортка data: відповідь має вигляд { "data": { ... } }. Вимкнути - JsonResource::withoutWrapping() у сервіс-провайдері, але для колекцій з пагінацією обгортка корисна (там же meta).

JSON:API. Якщо потрібен стандартний формат специфікації JSON:API (type, id, attributes, relationships, included), у Laravel 13 є вбудований JsonApiResource - php artisan make:resource PostResource --json-api.

Пастка: ресурс не захищає від N+1. Якщо в toArray звертатися до незавантажених зв'язків ($this->author->name), кожен елемент колекції - окремий запит. Звідси whenLoaded і with() у контролері.

Докладніше в документації: Laravel: API Resources

Laravel вирішує, як відповісти на виняток - HTML-сторінкою чи JSON, - за заголовком Accept запиту ($request->expectsJson()).

Помилка валідації для запиту з Accept: application/json:

HTTP/1.1 422 Unprocessable Content

{
  "message": "The email field is required. (and 1 more error)",
  "errors": {
    "email": ["The email field is required."],
    "password": ["The password field must be at least 8 characters."]
  }
}

Без Accept: application/json та сама помилка валідації - це редирект назад (302) з помилками в сесії, як для HTML-форм. Клієнт API отримає HTML сторінки замість зрозумілої помилки. Найчастіша причина «API повертає 302 замість 422».

Інші типові відповіді:

Ситуація Статус
немає чи недійсний токен (AuthenticationException) 401 {"message": "Unauthenticated."}
authorize() / політика відмовила 403
модель не знайдено (findOrFail, прив'язка маршруту) 404
перевищено ліміт (throttle) 429 з Retry-After
виняток у коді 500

APP_DEBUG=true додає до відповіді 500 повідомлення винятку, файл, рядок і стек. На продакшені - обов'язково false, інакше API розкриває внутрішню будову коду.

Примусово JSON для всіх маршрутів API - незалежно від заголовка клієнта:

// bootstrap/app.php
->withExceptions(function (Exceptions $exceptions): void {
    $exceptions->shouldRenderJsonWhen(
        fn (Request $request, Throwable $e) => $request->is('api/*') || $request->expectsJson(),
    );
})

Власний формат для конкретного винятку:

$exceptions->render(function (OrderAlreadyShippedException $e, Request $request) {
    return response()->json(['message' => 'Замовлення вже відправлено'], 409);
});

Для клієнтів API варто задокументувати: завжди надсилати Accept: application/json, а формат помилки - єдиний для всіх ендпойнтів.

Докладніше в документації: Laravel: рендеринг винятків як JSON

Laravel Sanctum - легка автентифікація для API: персональні токени доступу (мобільні застосунки, інтеграції) і сесійна автентифікація для SPA.

Модель користувача:

use Laravel\Sanctum\HasApiTokens;

class User extends Authenticatable
{
    use HasApiTokens;
}

Видача токена (наприклад, для входу з мобільного застосунку):

Route::post('/tokens', function (Request $request) {
    $request->validate([
        'email' => ['required', 'email'],
        'password' => ['required'],
        'device_name' => ['required', 'string', 'max:255'],
    ]);

    $user = User::where('email', $request->email)->first();

    if (! $user || ! Hash::check($request->password, $user->password)) {
        throw ValidationException::withMessages(['email' => ['Невірні облікові дані.']]);
    }

    return ['token' => $user->createToken($request->device_name)->plainTextToken];
});

Токен має вигляд 5|xYz...: id запису й випадкова частина.

Використання - заголовок Authorization:

GET /api/user
Authorization: Bearer 5|xYz...
Accept: application/json
Route::get('/user', fn (Request $request) => $request->user())->middleware('auth:sanctum');

Як Sanctum зберігає токени: у таблиці personal_access_tokens лежить лише SHA-256-хеш випадкової частини. Відкритий токен показується один раз при створенні - потім його неможливо відновити, лише створити новий. Витік бази не дає готових токенів.

Відкликання:

$request->user()->currentAccessToken()->delete();   // вихід з цього пристрою
$user->tokens()->delete();                            // вихід з усіх пристроїв

Термін дії: за замовчуванням токени не мають терміну дії. Його задають глобально (expiration у config/sanctum.php, хвилини) або для конкретного токена третім аргументом createToken. Прострочені записи прибирає sanctum:prune-expired у планувальнику.

Що варто зробити в продакшені:

  • обмежити частоту запитів до ендпойнта видачі токенів (захист від перебору паролів);
  • задати термін дії токенів;
  • device_name зрозумілий користувачу - щоб він міг побачити список пристроїв і відкликати зайвий;
  • префікс токенів (token_prefix у конфігурації) - сканери секретів (наприклад, GitHub) зможуть розпізнати токен, що потрапив у публічний репозиторій.

Докладніше в документації: Laravel Sanctum: API-токени

Form Request - окремий клас для валідації й авторизації запиту. Контролер отримує вже перевірені дані.

php artisan make:request StoreOrderRequest
class StoreOrderRequest extends FormRequest
{
    public function authorize(): bool
    {
        return $this->user()->can('create', Order::class);
    }

    public function rules(): array
    {
        return [
            'items' => ['required', 'array', 'min:1', 'max:50'],
            'items.*.product_id' => ['required', 'integer', Rule::exists('products', 'id')->where('active', true)],
            'items.*.qty' => ['required', 'integer', 'between:1,100'],
            'comment' => ['nullable', 'string', 'max:1000'],
        ];
    }
}
public function store(StoreOrderRequest $request): JsonResponse
{
    $order = $this->orders->create($request->user(), $request->validated());

    return (new OrderResource($order))->response()->setStatusCode(201);
}

Що відбувається автоматично:

  • Laravel створює запит і викликає authorize() до контролера. false - відповідь 403;
  • rules() - валідація; помилки - 422 з полем errors (для запитів з Accept: application/json);
  • контролер виконується лише якщо все пройшло.

Чому це краще за $request->validate() у контролері:

  • контролер коротший і читається як бізнес-логіка;
  • правила й авторизацію легко перевикористати (створення й оновлення часто ділять більшість правил);
  • $request->validated() - лише перевірені поля. Передавати їх у create() безпечно: зайве поле з тіла запиту (is_admin) туди не потрапить.

Корисні можливості:

  • prepareForValidation() - нормалізувати вхідні дані до перевірки (обрізати пробіли, привести телефон до одного формату);
  • after() - перевірки, що охоплюють кілька полів або потребують бази;
  • messages() і attributes() - власні тексти помилок і назви полів;
  • $stopOnFirstFailure - зупинити валідацію на першій помилці.

Пастки API:

  • межі масивів (max:50) і рядків обов'язкові: клієнт може надіслати мегабайти даних;
  • exists з умовами (where('active', true)) - інакше можна замовити неактивний чи чужий товар;
  • sometimes для PATCH: поле перевіряється, лише якщо прийшло, - часткове оновлення не вимагає всіх полів;
  • авторизація конкретного об'єкта ($this->route('order')) в authorize() - захист від доступу до чужих записів.

Докладніше в документації: Laravel: Form Request

Якщо передати в колекцію ресурсів пагінатор, Laravel додає до відповіді посилання й метадані пагінації:

public function index(Request $request)
{
    $posts = Post::query()
        ->with('author')
        ->latest()
        ->paginate(perPage: min((int) $request->integer('per_page', 20), 100));

    return PostResource::collection($posts);
}
{
  "data": [ { "id": 41, "title": "..." } ],
  "links": {
    "first": "https://example.com/api/posts?page=1",
    "last": "https://example.com/api/posts?page=12",
    "prev": null,
    "next": "https://example.com/api/posts?page=2"
  },
  "meta": {
    "current_page": 1,
    "from": 1,
    "last_page": 12,
    "path": "https://example.com/api/posts",
    "per_page": 20,
    "to": 20,
    "total": 235
  }
}

Три види пагінаторів:

Метод Що дає Ціна
paginate() номери сторінок, total, last_page додатковий COUNT(*)
simplePaginate() лише next/prev без підрахунку
cursorPaginate() курсор замість номера сторінки без OFFSET, стабільна на змінних даних

Як обрати:

  • адмінки й таблиці з переходом на довільну сторінку - paginate();
  • великі таблиці - COUNT(*) по мільйонах рядків дорогий, тож simplePaginate() або cursorPaginate();
  • стрічки й нескінченний скрол, мобільні застосунки - cursorPaginate(): без пропусків і дублікатів, коли між запитами додаються нові записи (зі OFFSET новий запис зсуває сторінки, і клієнт отримує один елемент двічі).

Що варто врахувати:

  • обмежити per_page зверху - інакше ?per_page=1000000 вивантажить усю таблицю;
  • стабільне сортування: для курсорної пагінації потрібне унікальне сортування (orderBy('created_at')->orderBy('id')), інакше записи з однаковою датою губляться;
  • параметри запиту зберігаються в посиланнях links через ->withQueryString() (фільтри, сортування);
  • N+1: with() до пагінації, а в ресурсі - whenLoaded;
  • власні метадані - метод paginationInformation() у класі колекції чи additional() для додаткових полів відповіді.

meta.total - частина контракту: перейшовши з paginate на cursorPaginate, ви прибираєте total, last_page і номери сторінок - це зміна, що ламає клієнтів.

Докладніше в документації: Laravel: API Resources і пагінація

Проблема: ресурс звертається до зв'язку, який не завантажено, - і на кожен елемент колекції виконується окремий запит.

// у ресурсі
'author' => new UserResource($this->author),   // N+1 для колекції з 50 постів - 51 запит

whenLoaded включає зв'язок у відповідь лише якщо його вже завантажено - і сам нічого не завантажує:

class PostResource extends JsonResource
{
    public function toArray(Request $request): array
    {
        return [
            'id' => $this->id,
            'title' => $this->title,
            'author' => new UserResource($this->whenLoaded('author')),
            'tags' => TagResource::collection($this->whenLoaded('tags')),
            'comments_count' => $this->whenCounted('comments'),
            'average_rating' => $this->whenAggregated('reviews', 'rating', 'avg'),
        ];
    }
}

Якщо зв'язок не завантажено, ключ зникає з відповіді (а не стає null).

Контролер вирішує, що завантажити:

$posts = Post::query()
    ->with(['author', 'tags'])
    ->withCount('comments')
    ->withAvg('reviews', 'rating')
    ->paginate();

return PostResource::collection($posts);

Той самий ресурс у списку (мінімум зв'язків) і на сторінці деталей (більше зв'язків) - різна кількість полів без двох окремих класів.

Включення на вимогу клієнта (?include=author,tags) - дозволений перелік, а не довільні зв'язки:

$allowed = ['author', 'tags', 'comments'];
$includes = array_intersect(explode(',', $request->string('include')), $allowed);

$posts = Post::with($includes)->paginate();

Пакет spatie/laravel-query-builder робить це разом із фільтрами й сортуванням. Вбудований JsonApiResource у Laravel 13 підтримує include за специфікацією JSON:API.

Інші помічники ресурсів:

  • $this->when($condition, $value) - поле за умовою (права, контекст);
  • $this->mergeWhen($condition, [...]) - кілька полів разом;
  • $this->whenPivotLoaded('role_user', fn () => ...) - дані проміжної таблиці.

Як ловити N+1 в API:

  • Model::preventLazyLoading(! app()->isProduction()) - виняток при ледачому завантаженні в розробці й тестах;
  • Telescope, Debugbar - кількість запитів на ендпойнт;
  • тест, що перевіряє кількість запитів для колекції (DB::enableQueryLog() / expectsDatabaseQueryCount).

Пастка: whenLoaded приховує «відсутні» дані - клієнт може не помітити, що зв'язок перестав приходити, бо контролер забув with(). Тому у важливих ендпойнтах варто мати тести структури відповіді.

Докладніше в документації: Laravel: умовні зв'язки

Важливо: у Laravel 11-13 група middleware api за замовчуванням не обмежує частоту запитів. Обмеження треба ввімкнути явно.

1. Визначити лімітер (в AppServiceProvider::boot()):

use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Support\Facades\RateLimiter;

RateLimiter::for('api', function (Request $request) {
    return Limit::perMinute(60)->by($request->user()?->id ?: $request->ip());
});

2. Підключити - для всієї групи api:

// bootstrap/app.php
->withMiddleware(function (Middleware $middleware): void {
    $middleware->throttleApi();            // throttle:api для групи api
    // $middleware->throttleApi(redis: true);
})

або точково на маршрутах: ->middleware('throttle:api').

Різні ліміти для різних клієнтів і дій:

RateLimiter::for('api', function (Request $request) {
    $user = $request->user();

    return match (true) {
        $user === null => Limit::perMinute(30)->by($request->ip()),
        $user->isPremium() => Limit::perMinute(600)->by($user->id),
        default => Limit::perMinute(120)->by($user->id),
    };
});

RateLimiter::for('login', fn (Request $request) => [
    Limit::perMinute(500),                                    // загальний для ендпойнта
    Limit::perMinute(5)->by($request->input('email').'|'.$request->ip()),
]);

RateLimiter::for('exports', fn (Request $request) => Limit::perDay(10)->by('exports:'.$request->user()->id));

Що отримує клієнт: при перевищенні - 429 Too Many Requests із заголовками Retry-After і X-RateLimit-Limit/X-RateLimit-Remaining. Власна відповідь - Limit::perMinute(60)->response(...).

Що враховувати:

  • ключ (by) визначає, кого рахувати: користувача, IP, токен, організацію. Для кількох лімітів з однаковим ключем - префікси ('minute:'.$id, 'day:'.$id), інакше лічильники змішаються;
  • IP за проксі - Laravel має довіряти проксі (trustProxies), інакше всі клієнти матимуть IP балансувальника й поділять один ліміт;
  • сховище лічильників - кеш застосунку. На кількох серверах потрібен спільний кеш (Redis), throttleApi(redis: true) використовує ефективніший Redis-middleware;
  • дорогі ендпойнти (пошук, експорт, генерація звітів) - окремі суворіші лімітери, а не один на все API;
  • ліміти на рівні CDN/WAF (Cloudflare) - перший рубіж від масових атак; лімітери застосунку - для справедливого розподілу між клієнтами.

Тестування: RateLimiter::clear($key) між тестами або перевірка 429 на N+1-му запиті.

Докладніше в документації: Laravel: обмеження частоти

Для власного SPA (Vue, React) Sanctum пропонує не токени, а звичайну сесію Laravel з cookie. Токен у JavaScript не потрапляє взагалі - XSS не зможе його вкрасти, а вихід знищує сесію на сервері.

Умова: SPA і API - на одному домені верхнього рівня (можна різні піддомени: app.example.com і api.example.com).

Налаштування на бекенді:

// bootstrap/app.php
->withMiddleware(function (Middleware $middleware): void {
    $middleware->statefulApi();   // сесія для запитів зі «своїх» доменів
})
SANCTUM_STATEFUL_DOMAINS=app.example.com
SESSION_DOMAIN=.example.com
  • statefulApi() додає в групу api middleware, що вмикає сесію й CSRF для запитів із доменів зі списку stateful;
  • SESSION_DOMAIN з крапкою - cookie сесії доступна піддоменам;
  • CORS (config/cors.php): дозволити домен SPA і supports_credentials => true, бо браузер має надсилати cookie в міжсайтовий запит.

Вхід з SPA:

await fetch('https://api.example.com/sanctum/csrf-cookie', { credentials: 'include' });

await fetch('https://api.example.com/login', {
  method: 'POST',
  credentials: 'include',
  headers: {
    'Content-Type': 'application/json',
    Accept: 'application/json',
    'X-XSRF-TOKEN': decodeURIComponent(readCookie('XSRF-TOKEN')),
  },
  body: JSON.stringify({ email, password }),
});
  1. /sanctum/csrf-cookie ставить cookie XSRF-TOKEN;
  2. вхід - звичайний маршрут логіну (Fortify, власний контролер з Auth::attempt);
  3. далі всі запити з credentials: 'include' і тим самим заголовком CSRF; маршрути захищені auth:sanctum.

Axios передає X-XSRF-TOKEN автоматично.

Як Sanctum розрізняє SPA і сторонніх клієнтів: за заголовками Origin/Referer. Якщо запит з домену зі списку stateful - сесія й CSRF; інакше - автентифікація токеном. Той самий auth:sanctum працює для обох.

Типові проблеми:

  • 419 CSRF token mismatch - не викликано /sanctum/csrf-cookie, не передано заголовок або домен SPA не в stateful (порт теж має збігатися: localhost:5173);
  • 401 після успішного входу - cookie не надсилається: немає credentials: 'include', неправильний SESSION_DOMAIN чи supports_credentials;
  • різні домени (app.com і api.net) - cookie-автентифікація не працює; потрібні токени чи BFF.

Коли не підходить: мобільні застосунки й сторонні інтеграції - для них токени.

Докладніше в документації: Laravel Sanctum: автентифікація SPA

Abilities у Sanctum - аналог OAuth-scopes: токен може робити лише те, що йому дозволено при створенні.

$token = $user->createToken('CI deploy', ['servers:read', 'servers:deploy'])->plainTextToken;
$readOnly = $user->createToken('Звіти в Google Sheets', ['reports:read'])->plainTextToken;

Без другого аргументу токен отримує ['*'] - усі можливості.

Перевірка в коді:

if ($request->user()->tokenCant('servers:deploy')) {
    abort(403);
}

Перевірка middleware на маршрутах (аліаси реєструються в bootstrap/app.php):

->withMiddleware(function (Middleware $middleware): void {
    $middleware->alias([
        'abilities' => \Laravel\Sanctum\Http\Middleware\CheckAbilities::class,
        'ability' => \Laravel\Sanctum\Http\Middleware\CheckForAnyAbility::class,
    ]);
})
Route::post('/servers/{server}/deploy', DeployController::class)
    ->middleware(['auth:sanctum', 'abilities:servers:deploy']);   // потрібні всі перелічені

Route::get('/reports', ReportController::class)
    ->middleware(['auth:sanctum', 'ability:reports:read,admin']);  // достатньо будь-якої

Головне правило: abilities обмежують токен, але не замінюють авторизацію користувача. Токен з servers:deploy дає право деплоїти лише ті сервери, до яких має доступ сам користувач. Перевірка - обидві:

public function deploy(Request $request, Server $server)
{
    abort_unless($request->user()->tokenCan('servers:deploy'), 403);
    $this->authorize('deploy', $server);   // політика: чи це сервер користувача
}

Особливість для SPA: при сесійній автентифікації (Sanctum SPA) tokenCan() завжди повертає true - запит від власного фронтенду, обмежень токена немає. Тому логіка «що можна користувачу» має бути в політиках, а abilities - лише додаткове обмеження для токенів.

Практичні поради:

  • принцип найменших прав: інтеграції видавати токени з мінімальним набором можливостей;
  • читабельні назви - ресурс:дія (orders:read, orders:write);
  • інтерфейс керування токенами для користувача: назва, можливості, дата останнього використання (last_used_at Sanctum оновлює автоматично), кнопка відкликання;
  • тести: Sanctum::actingAs($user, ['orders:read']) - і перевірка, що запис заборонено.

Докладніше в документації: Laravel Sanctum: можливості токенів

Версія в URL - найпоширеніший і найпростіший для клієнтів варіант:

// bootstrap/app.php
->withRouting(
    api: __DIR__.'/../routes/api.php',
    apiPrefix: 'api',
    then: function () {
        Route::middleware('api')->prefix('api/v2')->name('v2.')
            ->group(base_path('routes/api_v2.php'));
    },
)
routes/api.php     →  /api/v1/...  (або /api/...)
routes/api_v2.php  →  /api/v2/...

Альтернативи - версія в заголовку (Accept: application/vnd.myapp.v2+json) чи окремий параметр. Вони «чистіші» з погляду REST, але гірше видимі в логах, кешах CDN і при налагодженні.

Що саме відрізняється між версіями - здебільшого формат даних, а не бізнес-логіка. Тому дублювати весь код не потрібно:

app/Http/Controllers/Api/V1/OrderController.php
app/Http/Controllers/Api/V2/OrderController.php   ← тонкі контролери
app/Http/Resources/V1/OrderResource.php
app/Http/Resources/V2/OrderResource.php           ← різний формат відповіді
app/Actions/CreateOrder.php                         ← спільна логіка
  • ресурси й Form Request-и - за версіями (формат входу й виходу);
  • сервіси, actions, моделі, політики - спільні;
  • нова версія створюється лише для ендпойнтів, що змінилися; решта може посилатися на контролери попередньої версії.

Коли нова версія потрібна - лише для ламаючих змін: перейменування чи видалення полів, зміна типів, обов'язкові нові параметри, зміна семантики. Додавання нових полів і ендпойнтів - не привід для нової версії.

Підтримка старих версій:

  • заголовки застарівання на відповідях старої версії (Deprecation, Sunset з датою вимкнення) і посилання на інструкцію з міграції;
  • моніторинг використання: скільки запитів і від яких клієнтів іде на стару версію - без цього неможливо вирішити, коли її вимикати;
  • мобільні клієнти оновлюються роками - стара версія може жити довго, тож кожна нова версія - це додаткова підтримка.

Тестування: набори тестів для кожної підтримуваної версії - зміна спільної логіки не має ламати стару версію.

Альтернатива версіям - еволюція без ламання: додавати, але не змінювати; нові поля замість зміни старих; «розширювані» енуми. Багато команд обходяться однією версією роками, якщо дотримуються цих правил.

Докладніше в документації: Laravel: групи маршрутів

Sanctum і Passport обидва дають автентифікацію API, але розв'язують різні задачі.

Sanctum - для власних клієнтів:

  • сесійна автентифікація для власного SPA;
  • персональні токени для мобільних застосунків, CLI, інтеграцій, які користувач створює сам;
  • простий: одна таблиця токенів, мінімум налаштувань.

Passport - повноцінний OAuth 2.0 сервер. Потрібен, коли:

1. Сторонні застосунки отримують доступ від імені користувачів - сценарій «Увійти через ваш сервіс» / «Дозволити застосунку X доступ до вашого облікового запису»:

Застосунок партнера → перенаправлення на ваш сайт → користувач погоджується
→ партнер отримує токен з обмеженими правами (scopes)

Це Authorization Code flow (з PKCE для публічних клієнтів). Користувач не передає партнеру пароль, бачить, які права надає, і може відкликати доступ.

2. Сервер-сервер інтеграції за стандартом - Client Credentials grant: машинний клієнт отримує короткоживучий токен без участі користувача.

3. Потрібен стандарт, який розуміють сторонні інструменти - бібліотеки OAuth-клієнтів, API-шлюзи, OpenID Connect-подібні сценарії.

4. Refresh-токени й короткоживучі токени доступу зі стандартною логікою оновлення.

Що дає Passport:

  • реєстрація OAuth-клієнтів (php artisan passport:client), екран згоди;
  • scopes (Passport::tokensCan([...])) і перевірка через middleware;
  • токени доступу у форматі JWT, підписані ключами (passport:keys);
  • терміни дії й refresh-токени (Passport::tokensExpireIn(), refreshTokensExpireIn()).

Ціна Passport:

  • складніша конфігурація й більше таблиць;
  • ключі шифрування треба безпечно зберігати й розгортати на всіх серверах;
  • OAuth - великий стандарт з багатьма способами помилитися (redirect URI, PKCE, зберігання секретів клієнтів).

Правило вибору:

Сценарій Інструмент
власний SPA Sanctum (сесія)
власний мобільний застосунок Sanctum (токени)
користувач створює токени для своїх скриптів Sanctum
сторонні застосунки з доступом від імені користувачів Passport
ваш сервіс як провайдер «Увійти через ...» Passport
сервер-сервер за стандартом OAuth Passport (client credentials)

Поширена помилка - Passport «на виріст» для звичайного SPA чи мобільного застосунку: складність OAuth без жодної з його переваг.

Докладніше в документації: Laravel Passport

Стандартні JSON-помилки Laravel різняться за форматом: {"message": "..."} для одних, {"message", "errors"} для валідації. Для публічного API зручніше один формат - наприклад, Problem Details (RFC 9457, application/problem+json).

Централізований рендеринг у bootstrap/app.php:

use Illuminate\Validation\ValidationException;
use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface;

->withExceptions(function (Exceptions $exceptions): void {
    $exceptions->shouldRenderJsonWhen(fn (Request $r) => $r->is('api/*') || $r->expectsJson());

    $exceptions->render(function (ValidationException $e, Request $request) {
        if (! $request->is('api/*')) {
            return null;   // стандартна поведінка для веб-форм
        }

        return response()->json([
            'type' => 'https://api.example.com/problems/validation',
            'title' => 'Дані не пройшли перевірку',
            'status' => 422,
            'errors' => $e->errors(),
        ], 422, ['Content-Type' => 'application/problem+json']);
    });

    // ApiProblemException - власний базовий клас доменних помилок застосунку
    $exceptions->render(function (ApiProblemException $e, Request $request) {
        if ($request->is('api/*')) {
            return response()->json([
                'type' => $e->problemType(),
                'title' => $e->getMessage(),
                'status' => $e->status(),
            ], $e->status(), ['Content-Type' => 'application/problem+json']);
        }
    });
})

Доменні винятки з власним рендерингом - клас знає свій статус і тип:

final class OrderAlreadyShipped extends Exception
{
    public function render(Request $request): JsonResponse
    {
        return response()->json([
            'type' => 'https://api.example.com/problems/order-already-shipped',
            'title' => 'Замовлення вже відправлено',
            'status' => 409,
            'order_id' => $this->orderId,
        ], 409, ['Content-Type' => 'application/problem+json']);
    }
}

Що врахувати:

  • винятки фреймворку теж мають іти в загальному форматі: AuthenticationException (401), AuthorizationException/AccessDeniedHttpException (403), NotFoundHttpException і ModelNotFoundException (404), ThrottleRequestsException (429), будь-який HttpExceptionInterface - через $e->getStatusCode();
  • 500 на продакшені - без деталей винятку: загальний title і ідентифікатор для підтримки (instance чи trace_id), за яким знайдеться запис у логах;
  • звітування не змінюється: render впливає лише на відповідь, винятки й далі логуються й ідуть у Sentry/Flare;
  • type - стабільний URL з описом помилки в документації; клієнти орієнтуються на нього, а не на текст title;
  • тести: для кожного типу помилки - перевірка статусу, Content-Type і структури (assertJsonStructure).

Зміна формату помилок - ламаюча зміна для наявних клієнтів, тож його варто обрати на старті API.

Докладніше в документації: Laravel: рендеринг винятків

Час відповіді API складається з запитів до бази, гідрації моделей Eloquent, серіалізації ресурсів і розміру відповіді. Оптимізація - по кожному кроку, після вимірювання.

1. Запити до бази:

  • N+1 - with() у контролері, whenLoaded у ресурсах, Model::preventLazyLoading() у розробці;
  • лише потрібні колонки: select(['id', 'title', 'author_id', 'created_at']) - менше даних з бази і менше пам'яті на гідрацію (не забути зовнішні ключі для with);
  • агрегати в базі: withCount, withSum замість завантаження зв'язків для підрахунку;
  • індекси під фільтри й сортування ендпойнта.

2. Обсяг відповіді:

  • пагінація обов'язкова для колекцій, з верхньою межею per_page;
  • cursorPaginate/simplePaginate - без COUNT(*) на великих таблицях;
  • розріджені поля й включення на вимогу (?fields[posts]=id,title, ?include=author) - клієнт отримує лише потрібне. Вбудований JsonApiResource Laravel 13 підтримує обидва механізми за специфікацією JSON:API;
  • стиснення (gzip/brotli) на рівні вебсервера.

3. Гідрація й серіалізація:

  • Eloquent-моделі дорогі для тисяч рядків. Для великих вивантажень - toBase()/query builder без моделей або lazy()/cursor() з потоковою відповіддю (response()->streamJson()), а не масив у пам'яті;
  • важкі обчислення в toArray (форматування, URL, звернення до сервісів) множаться на кількість елементів - винести в запит чи кеш.

4. Кешування:

  • HTTP-кешування: ETag/Last-Modified і 304 Not Modified - клієнт не завантажує незмінене; Cache-Control для публічних даних - кеш на CDN;
  • кеш застосунку для дорогих агрегацій (Cache::flexible() - stale-while-revalidate: віддає застаріле значення й оновлює у фоні);
  • кеш на рівні запитів до бази - точково, з продуманою інвалідацією.

5. Інфраструктура:

  • Octane (FrankenPHP, Swoole, RoadRunner) - застосунок у пам'яті між запитами, без завантаження фреймворку на кожен запит;
  • черги для всього, що не потрібне для відповіді (листи, вебхуки, аналітика);
  • асинхронні операції - 202 Accepted з посиланням на статус для довгих задач замість очікування в запиті.

Як вимірювати: Telescope/Debugbar (кількість запитів і час), Pulse (повільні ендпойнти й запити в продакшені), профайлер для гарячих точок у серіалізації. Оптимізація без вимірювань часто прискорює не те.

Докладніше в документації: Laravel: JSON:API ресурси