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

Питання на співбесіді з API

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

100 питань

Якщо передати в колекцію ресурсів пагінатор, 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: можливості токенів

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

Поширена схема (близька до JSON:API):

GET /api/vacancies?filter[city]=kyiv&filter[remote]=1&filter[salary_from]=2000
    &sort=-published_at,title
    &fields[vacancies]=id,title,salary
    &include=company
    &page[size]=20
  • filter[...] - умови; для діапазонів - суфікси (salary_from/salary_to) або оператори (filter[salary][gte]=2000);
  • sort - список полів через кому, - - за спаданням;
  • fields[тип] - лише потрібні поля (sparse fieldsets): мобільному списку не треба повного опису вакансії;
  • include - пов'язані ресурси в тій самій відповіді.

У Laravel розбір таких параметрів дає spatie/laravel-query-builder:

QueryBuilder::for(Vacancy::class)
    ->allowedFilters(['city', AllowedFilter::exact('remote'), AllowedFilter::scope('salary_from')])
    ->allowedSorts(['published_at', 'title'])
    ->allowedFields(['id', 'title', 'salary'])
    ->allowedIncludes(['company'])
    ->paginate();

А вбудовані JSON:API-ресурси Laravel (JsonApiResource) самі обробляють fields і include у відповіді.

Безпека й продуктивність - головне:

  • білий список полів для фільтрів, сортування й include. Сортування за довільною колонкою з параметра - це і SQL-ризики, і повільні запити за полями без індексу. Невідомий параметр - помилка 400, а не тихе ігнорування;
  • індекси під реальні фільтри: кожна дозволена комбінація фільтр + сортування - потенційний запит, і для частих комбінацій потрібні складені індекси;
  • обмеження глибини include і кількості елементів - інакше один запит витягне половину бази;
  • приховані поля: fields не повинен відкривати поля, яких немає в звичайній відповіді (password_hash, внутрішні примітки).

Пошук за текстом - окремий параметр (filter[q]=laravel чи q=), який веде в повнотекстовий пошук (Scout, Meilisearch), а не в LIKE '%...%' по кількох колонках.

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

Докладніше в документації: JSON:API: sparse fieldsets

Генерація звіту, імпорт файлу, відео-конвертація займають хвилини. Тримати HTTP-з'єднання весь цей час не можна: спрацюють тайм-аути проксі й клієнта, а повтор запиту запустить роботу вдруге.

Шаблон «асинхронна операція»:

1. Запит створює задачу й одразу відповідає 202 Accepted:

POST /api/exports
{"type": "orders", "from": "2026-09-01"}

HTTP/1.1 202 Accepted
Location: /api/exports/7f3a
Retry-After: 5

{"data": {"id": "7f3a", "status": "queued"}}

202 означає «прийнято до обробки, але ще не виконано». Location вказує, де стежити за результатом.

2. Клієнт опитує ресурс статусу:

GET /api/exports/7f3a

{"data": {"id": "7f3a", "status": "processing", "progress": 45}}

3. Після завершення - посилання на результат:

{"data": {"id": "7f3a", "status": "completed", "result_url": "/api/exports/7f3a/download"}}

Або 303 See Other з Location на готовий ресурс. При помилці - status: "failed" з описом.

У Laravel:

public function store(StoreExportRequest $request): JsonResponse
{
    $export = Export::create([...$request->validated(), 'status' => 'queued', 'user_id' => $request->user()->id]);

    GenerateExport::dispatch($export);

    return ExportResource::make($export)
        ->response()
        ->setStatusCode(202)
        ->header('Location', route('exports.show', $export));
}

Джоба оновлює status і progress моделі.

Альтернативи опитуванню:

  • вебхук - сервер сам повідомляє клієнта про завершення (для інтеграцій сервер-сервер);
  • WebSocket/SSE (Laravel Reverb) - для інтерфейсу користувача;
  • Retry-After - підказка клієнту, як часто опитувати.

Що важливо:

  • ідемпотентність створення - повтор POST після обриву з'єднання не повинен ставити другу задачу (Idempotency-Key);
  • авторизація ресурсу статусу - лише власник бачить свою операцію;
  • термін життя результату й статусу (видаляти через N днів);
  • скасування - DELETE /api/exports/7f3a чи POST .../cancel, якщо операція довга.

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

HTTP/1.1 дозволяє на одному з'єднанні лише один запит за раз. Браузер відкриває близько 6 з'єднань на домен, і решта запитів чекає в черзі. Звідси старі прийоми оптимізації:

  • об'єднувати запити - «товсті» ендпойнти, що повертають усе для екрана одразу;
  • «шардинг» доменів - api1., api2., щоб обійти ліміт з'єднань;
  • склеювання ресурсів, спрайти.

HTTP/2 мультиплексує: багато запитів паралельно в одному з'єднанні, плюс стиснення заголовків (HPACK). HTTP/3 робить те саме поверх QUIC (UDP): втрата пакета в одному потоці не блокує інші, швидше встановлення з'єднання, краще поводження при зміні мережі (Wi-Fi → мобільна).

Що це змінює для API:

  • дрібні запити стали дешевшими. Кілька паралельних GET до різних ресурсів більше не впираються в ліміт з'єднань. Агрегувальні «все-в-одному» ендпойнти менш потрібні, а дрібні ресурси краще кешуються окремо;
  • шардинг доменів шкідливий: кожен домен - окреме з'єднання з TLS-рукостисканням, і мультиплексування втрачається;
  • заголовки дешеві - стиснення HPACK/QPACK зменшує вартість повторюваних заголовків (Authorization, cookies);
  • довгі з'єднання для стримінгу (SSE) не займають ліміт браузера: на HTTP/1.1 кілька вкладок з SSE вичерпують 6 з'єднань, на HTTP/2 - ні.

Чого HTTP/2 не скасовує:

  • затримка (latency) кожного запиту лишається: послідовні залежні запити («водоспад» - спершу користувач, потім його замовлення, потім товари) все одно повільні. Від водоспаду захищає проєктування (include, вкладені ресурси), а не протокол;
  • вартість на сервері: паралельні запити - це паралельна робота PHP-воркерів і бази;
  • Server Push з HTTP/2 практично мертвий - браузери прибрали його підтримку; замість нього - 103 Early Hints і preload.

Де вмикається: HTTP/2 і HTTP/3 налаштовуються на вебсервері чи CDN (Nginx, Caddy, Cloudflare), а не в Laravel. Для API за CDN клієнт спілкується з CDN по HTTP/3, а CDN з сервером - по HTTP/1.1 чи 2, і переваги для клієнта все одно є.

Перевірка: колонка Protocol у DevTools (h2, h3) чи curl --http2 -I.

Докладніше в документації: HTTP/2

Постійні з'єднання (keep-alive). Встановлення TCP-з'єднання й TLS-рукостискання коштують кількох обмінів пакетами - на віддалений сервер це десятки чи сотні мілісекунд. HTTP/1.1 за замовчуванням тримає з'єднання відкритим для наступних запитів, а HTTP/2 і HTTP/3 побудовані на одному довгоживучому з'єднанні.

Для клієнта, що робить багато запитів (інтеграція, черга, що відправляє тисячі запитів), важливо перевикористовувати з'єднання. У PHP-FPM кожен запит до застосунку - новий процес обробки, тож HTTP-клієнт не переживає між запитами; але в межах однієї джоби чи команди варто тримати один екземпляр клієнта. Для серверних процесів, що живуть довго (Octane, воркери черг), пул з'єднань дає відчутний виграш.

Тайм-аути - обов'язкові. Запит без тайм-ауту до сервісу, що завис, тримає воркер PHP хвилинами, і кілька таких запитів вичерпують пул воркерів - падає весь застосунок.

Http::connectTimeout(3)      // встановлення з'єднання
    ->timeout(10)            // уся відповідь
    ->retry(3, 200, throw: false)
    ->get('https://api.partner.com/rates');

Види тайм-аутів:

  • з'єднання (connect) - короткий, 2-5 с: якщо сервер не відповідає на з'єднання, далі чекати марно;
  • відповіді (read/total) - під очікувану тривалість операції з запасом;
  • на сервері - max_execution_time, request_terminate_timeout у PHP-FPM, тайм-аути Nginx і балансувальника.

Узгодженість тайм-аутів по ланцюжку: зовнішній тайм-аут має бути більшим за внутрішні. Якщо балансувальник обриває через 30 с, а PHP працює до 60 с, клієнт отримає 504, а сервер ще пів хвилини витрачатиме ресурси на відповідь, яку ніхто не прочитає. І навпаки, тайм-аут HTTP-клієнта всередині запиту має вкладатися в загальний час обробки.

Повтори - лише для ідемпотентних запитів і тимчасових помилок (мережа, 502/503/504, 429), з експоненційною затримкою й випадковим розкидом.

Тайм-аут простою keep-alive на сервері (Nginx keepalive_timeout) має бути більшим, ніж у балансувальника перед ним, - інакше сервер закриває з'єднання, яке балансувальник вважає живим, і частина запитів падає з 502.

Для довгих операцій тайм-аути не збільшують до хвилин - такі операції роблять асинхронними (202 Accepted + статус).

Докладніше в документації: Заголовок Keep-Alive

Дві протилежні проблеми REST:

  • надлишкові дані (over-fetching) - відповідь містить усе, хоча клієнту потрібна дрібка;
  • недостатні дані (under-fetching) - для одного екрана потрібно кілька послідовних запитів.

На сервері обидві часто перетворюються на N+1: ресурс звертається до зв'язку для кожного елемента списку.

// контролер
return PostResource::collection(Post::paginate(20));

// ресурс
public function toArray($request): array
{
    return [
        'id' => $this->id,
        'title' => $this->title,
        'author' => new UserResource($this->author),   // окремий запит на кожен пост
    ];
}

20 постів - 21 запит до бази.

Рішення 1 - жадібне завантаження + умовні зв'язки:

// контролер
return PostResource::collection(Post::with('author')->paginate(20));

// ресурс
'author' => UserResource::make($this->whenLoaded('author')),
'comments_count' => $this->whenCounted('comments'),

whenLoaded додає зв'язок у відповідь лише якщо його завантажили. Ресурс більше не робить запитів сам, а контролер явно вирішує, що завантажити.

Рішення 2 - include на запит клієнта: GET /api/posts?include=author,tags. Контролер завантажує лише дозволені зв'язки з цього списку (spatie/laravel-query-builder чи вбудовані JSON:API-ресурси Laravel, які серіалізують зв'язок лише коли клієнт його запросив).

Рішення 3 - вибір полів: fields[posts]=id,title - мобільний список не тягне тіло статті.

Захист від N+1 у розробці:

// AppServiceProvider::boot()
Model::preventLazyLoading(! app()->isProduction());

Ліниве завантаження зв'язку кидає виняток у розробці й тестах - N+1 видно одразу. Також Model::automaticallyEagerLoadRelationships() (Laravel 12+) підвантажує зв'язки для всієї колекції автоматично - зручно, але не замінює свідомого with().

Агрегати замість колекцій: withCount('comments'), withSum, withExists - кількість одним запитом, а не завантаження всіх коментарів заради count().

Коли REST не вистачає: якщо різні клієнти постійно потребують дуже різних наборів даних, а include/fields розростаються, - це аргумент за GraphQL або окремі ендпойнти під конкретний клієнт (Backend for Frontend).

Перевірка: Debugbar, Telescope чи тест, що рахує запити (DB::enableQueryLog() + expect(count(DB::getQueryLog()))->toBeLessThan(5)).

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

Обидва генерують документацію API з коду Laravel, але різними способами.

Scramble:

  • статичний аналіз коду: читає маршрути, Form Request, правила валідації, API-ресурси й типи повернення - без анотацій;
  • результат - специфікація OpenAPI 3.1 і веб-інтерфейс документації на її основі;
  • документація оновлюється автоматично разом з кодом;
  • доповнення через PHPDoc і атрибути; платна версія підтримує популярні пакети (Laravel Data, Query Builder).

Scribe:

  • комбінація джерел: правила валідації з Form Request, PHPDoc-анотації (@group, @bodyParam, @response) і, за бажанням, реальні запити до ендпойнтів у локальному оточенні, щоб отримати справжні приклади відповідей;
  • генерує HTML-документацію з прикладами коду кількома мовами й кнопкою «Try It Out», а також колекцію Postman і специфікацію OpenAPI;
  • документація генерується командою (php artisan scribe:generate) - статичні файли, які можна викласти будь-де.

Як обрати:

Критерій Scramble Scribe
зусилля на старті мінімальні більше анотацій
актуальність завжди з коду після перегенерації
реальні приклади відповідей з типів і ресурсів можуть братися з живих запитів
основний артефакт OpenAPI-специфікація HTML-документація + Postman
точність при складній логіці залежить від аналізу коду контролюється анотаціями

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

  • внутрішнє API для власного фронтенду - Scramble: найменше підтримки, специфікація придатна для генерації TypeScript-клієнта;
  • публічна документація з гайдами й багатьма прикладами - Scribe чи окремий інструмент документації поверх експортованої специфікації;
  • реальні запити в Scribe виконуються з даними й побічними ефектами - їх налаштовують лише для безпечних ендпойнтів і окремої бази;
  • у будь-якому разі експортовану специфікацію варто тримати в репозиторії й перевіряти в CI: лінтинг і пошук змін, що ламають клієнтів.

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

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

Fluent-перевірки дають змогу описати відповідь повністю: значення, типи, вкладені структури й відсутність зайвих полів.

use Illuminate\Testing\Fluent\AssertableJson;

$this->getJson('/api/vacancies?filter[city]=kyiv')
    ->assertOk()
    ->assertJson(fn (AssertableJson $json) => $json
        ->has('data', 3, fn (AssertableJson $vacancy) => $vacancy
            ->where('city', 'kyiv')
            ->whereType('id', 'integer')
            ->whereType('salary_from', 'integer|null')
            ->has('company', fn (AssertableJson $company) => $company
                ->hasAll(['id', 'name'])
                ->missing('owner_email')
                ->etc()
            )
            ->etc()
        )
        ->has('meta')
        ->has('links')
    );

Основні методи:

  • where('key', $value) - точне значення; можна передати замикання для власної умови;
  • whereType('key', 'string') - тип (string, integer, array, null, через | - кілька);
  • has('key'), has('items', 3) - наявність і кількість;
  • has('data', 3, fn ...) - кількість і перевірка першого елемента колекції;
  • each(fn ...) - перевірка кожного елемента;
  • hasAll([...]), hasAny([...]), missing('key'), missingAll([...]).

Найважливіше - строгість за замовчуванням. Кожен рівень, перевірений через замикання, вимагає, щоб усі ключі на цьому рівні були перевірені. Незгадане поле - провал тесту. etc() явно дозволяє «інші поля теж можуть бути».

Це захищає від головного ризику API: нове поле, що випадково потрапило у відповідь (наприклад, хтось додав модель цілком замість ресурсу, і в JSON з'явилися внутрішні поля). Без etc() тест це помітить.

Коли що обирати:

  • простий тест одного значення - assertJsonPath;
  • контракт ресурсу (усі поля й типи, нічого зайвого) - fluent-перевірки без etc() на ключових рівнях;
  • дуже великі відповіді - перевірка відповідності специфікації OpenAPI (бібліотеки валідації відповідей за схемою), щоб не дублювати опис контракту в тестах і в документації.

Пастка: has('data', 3, ...) перевіряє замиканням лише перший елемент. Для перевірки всіх - has('data', 3) і окремо ->each(...) або ->has('data.0', ...)/'data.1' для конкретних позицій.

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

Якщо API описано специфікацією OpenAPI, клієнтський код не треба писати й підтримувати вручну: типи запитів і відповідей генеруються з контракту.

Варіант 1 - лише типи (openapi-typescript + openapi-fetch):

npx openapi-typescript ./openapi.json -o ./src/api/schema.d.ts
import createClient from 'openapi-fetch';
import type { paths } from './api/schema';

const api = createClient<paths>({ baseUrl: '/api' });

const { data, error } = await api.GET('/vacancies/{id}', {
  params: { path: { id: 42 } },
});
// data - точний тип відповіді 200, error - тип помилки з опису

Шлях, параметри й тип відповіді перевіряються компілятором: друкарська помилка в URL чи відсутній обов'язковий параметр - помилка TypeScript, а не 404 у продакшені.

Варіант 2 - повний SDK (OpenAPI Generator, Hey API, Orval, Kiota): згенеровані класи чи функції для кожної операції, моделі, інколи - готові хуки для TanStack Query.

Процес, що працює:

  1. специфікація - артефакт бекенду (Scramble scramble:export чи написана вручну) у репозиторії чи CI;
  2. генерація клієнта - крок збирання фронтенду або окремий пакет;
  3. зміна API ламає збирання фронтенду, якщо клієнтський код не відповідає новому контракту, - помилка виявляється до деплою.

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

  • якість згенерованого залежить від якості специфікації: type: object без властивостей дасть Record<string, unknown>, неописані помилки - відсутність типів для них. Генерація клієнтів швидко показує прогалини в документації;
  • nullable і необов'язкові поля - розрізняти «поле може бути null» і «поля може не бути» (required). Неточність тут - джерело помилок undefined на клієнті;
  • типи не перевіряють дані під час виконання. Відповідь сервера, що не відповідає контракту, тихо пройде. Для критичних даних - валідація схемою (Zod, згенерований зі специфікації);
  • не редагувати згенерований код вручну - зміни зникнуть при наступній генерації. Розширення - обгортками;
  • версії інструментів: не всі генератори повністю підтримують OpenAPI 3.1 (типи-масиви [string, 'null'], $ref поряд з іншими полями).

Альтернатива в межах Laravel + Inertia: Wayfinder генерує типізовані функції для маршрутів і дій контролерів без проміжної специфікації.

Докладніше в документації: openapi-typescript

JSON:API - специфікація формату JSON-відповідей і правил запитів для API. Замість того, щоб кожна команда вигадувала власну структуру, вона фіксує готові рішення.

{
  "data": {
    "type": "posts",
    "id": "1",
    "attributes": { "title": "Laravel 13", "published_at": "2026-09-30T10:00:00Z" },
    "relationships": {
      "author": { "data": { "type": "users", "id": "7" } }
    }
  },
  "included": [
    { "type": "users", "id": "7", "attributes": { "name": "Оля" } }
  ]
}

Що стандартизує:

  • структура ресурсу: type, id (завжди рядок), attributes, relationships, links, meta;
  • include - пов'язані ресурси в масиві included, кожен один раз, навіть якщо на нього посилаються десятки записів;
  • sparse fieldsets - fields[posts]=title;
  • сортування, пагінація, фільтрація - назви параметрів (sort=-published_at, page[...], filter[...]);
  • формат помилок - масив errors з status, code, title, detail, source.pointer;
  • медіатип - application/vnd.api+json.

Laravel 13 має вбудовані JSON:API-ресурси:

php artisan make:resource PostResource --json-api
class PostResource extends JsonApiResource
{
    public $attributes = ['title', 'body', 'published_at'];

    public $relationships = ['author', 'comments'];
}

JsonApiResource формує структуру data/attributes/relationships, обробляє include і fields із запиту, серіалізує зв'язки лише коли клієнт їх запросив і виставляє правильний Content-Type. Глибину вкладених include обмежує JsonApiResource::maxRelationshipDepth(). Розбір фільтрів і сортування Laravel лишає за пакетами на кшталт spatie/laravel-query-builder.

Переваги стандарту:

  • не потрібно вигадувати й документувати формат - достатньо послатися на специфікацію;
  • готові клієнтські бібліотеки вміють нормалізувати included, будувати запити з include і fields;
  • дедуплікація пов'язаних ресурсів зменшує розмір відповідей.

Недоліки:

  • багатослівність: для простих API структура надлишкова, а клієнту без бібліотеки доводиться «склеювати» relationships з included;
  • рядкові id і обгортки незвичні для фронтенд-розробників;
  • проблеми продуктивності з include на сервері лишаються: кожен дозволений зв'язок треба завантажувати жадібно.

Коли обирати: публічні API й інтеграції, де передбачуваність формату важить більше за компактність, або коли клієнти вже використовують JSON:API-бібліотеки.

Докладніше в документації: Специфікація JSON:API

Коли контракт API погоджено (специфікація OpenAPI), фронтенд не повинен чекати на готовий бекенд. Мок-сервер відповідає за специфікацією.

Prism - мок-сервер, що читає OpenAPI:

npx @stoplight/prism-cli mock openapi.yaml
# слухає на http://127.0.0.1:4010
  • статичні відповіді з полів example/examples специфікації;
  • динамічні (--dynamic) - згенеровані дані, що відповідають схемам;
  • валідація запитів: запит з неправильним тілом чи без обов'язкового параметра отримає 422 - фронтенд одразу бачить, що надсилає не те;
  • вибір сценарію заголовком Prefer: code=404 чи Prefer: example=empty - перевірка обробки помилок;
  • режим проксі - перевіряє, що справжній сервер відповідає специфікації (контрольна точка між бекендом і контрактом).

MSW (Mock Service Worker) - моки на рівні клієнта:

import { http, HttpResponse } from 'msw';

export const handlers = [
  http.get('/api/vacancies/:id', ({ params }) =>
    HttpResponse.json({ data: { id: Number(params.id), title: 'Laravel Developer' } }),
  ),
  http.post('/api/applications', () =>
    HttpResponse.json({ errors: { email: ['Обов\'язкове поле'] } }, { status: 422 }),
  ),
];

Service Worker у браузері (чи перехоплювач у Node для тестів) відповідає на запити застосунку. Код застосунку не знає про моки - робить звичайні fetch.

Порівняння:

  • Prism - окремий сервер, що бере дані зі специфікації: моки не розходяться з контрактом;
  • MSW - моки в коді, повний контроль над сценаріями (затримки, помилки мережі, стан між запитами), спільні для розробки, тестів і Storybook. Але їх треба підтримувати вручну або генерувати зі специфікації.

Головний ризик моків - розходження з реальністю. Фронтенд «працює» з моками, а з реальним API - ні. Захист:

  • генерувати моки й типи з тієї самої специфікації, що й документацію;
  • контрактні тести на бекенді (відповіді відповідають специфікації);
  • регулярна перевірка на реальному тестовому оточенні до релізу.

Для бекенду на Laravel аналогічна задача - Http::fake() для сторонніх API в тестах.

Докладніше в документації: Prism: мок-сервер для OpenAPI

Питання з реальних технічних співбесід - 100 питань у 7 темах, розібраних із відповідями. Нижче - розбивка за рівнями та темами, якщо хочете звузити підготовку.

Рівні
Junior 35 Middle 35 Senior 30

Готуєтесь до співбесіди не просто так: зараз на сайті 146 відкритих вакансій Laravel і PHP. Переглянути вакансії