Питання на співбесіді з 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 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-му запиті.
Для власного 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()додає в групуapimiddleware, що вмикає сесію й 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 }),
});
/sanctum/csrf-cookieставить cookieXSRF-TOKEN;- вхід - звичайний маршрут логіну (Fortify, власний контролер з
Auth::attempt); - далі всі запити з
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_atSanctum оновлює автоматично), кнопка відкликання; - тести:
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-описі - інакше клієнти вгадують.
Генерація звіту, імпорт файлу, відео-конвертація займають хвилини. Тримати 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, якщо операція довга.
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.
Постійні з'єднання (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 + статус).
Дві протилежні проблеми 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: лінтинг і пошук змін, що ламають клієнтів.
Чого не робить жоден генератор: не придумує зрозумілих описів, бізнес-правил і сценаріїв використання - ці частини пишуться людьми.
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' для конкретних позицій.
Якщо 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.
Процес, що працює:
- специфікація - артефакт бекенду (Scramble
scramble:exportчи написана вручну) у репозиторії чи CI; - генерація клієнта - крок збирання фронтенду або окремий пакет;
- зміна API ламає збирання фронтенду, якщо клієнтський код не відповідає новому контракту, - помилка виявляється до деплою.
Що варто врахувати:
- якість згенерованого залежить від якості специфікації:
type: objectбез властивостей дастьRecord<string, unknown>, неописані помилки - відсутність типів для них. Генерація клієнтів швидко показує прогалини в документації; nullableі необов'язкові поля - розрізняти «поле може бутиnull» і «поля може не бути» (required). Неточність тут - джерело помилокundefinedна клієнті;- типи не перевіряють дані під час виконання. Відповідь сервера, що не відповідає контракту, тихо пройде. Для критичних даних - валідація схемою (Zod, згенерований зі специфікації);
- не редагувати згенерований код вручну - зміни зникнуть при наступній генерації. Розширення - обгортками;
- версії інструментів: не всі генератори повністю підтримують OpenAPI 3.1 (типи-масиви
[string, 'null'],$refпоряд з іншими полями).
Альтернатива в межах Laravel + Inertia: Wayfinder генерує типізовані функції для маршрутів і дій контролерів без проміжної специфікації.
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-бібліотеки.
Коли контракт 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 в тестах.
Питання з реальних технічних співбесід - 100 питань у 7 темах, розібраних із відповідями. Нижче - розбивка за рівнями та темами, якщо хочете звузити підготовку.
Готуєтесь до співбесіди не просто так: зараз на сайті 146 відкритих вакансій Laravel і PHP. Переглянути вакансії