Питання на співбесіді з API
Питання з реальних співбесід з відповідями: Laravel і PHP, бази даних, JavaScript і фронтенд, Git, Docker, API, безпека й архітектура. Тими самими темами, що й тести.
100 питань
Ендпоінт вебхука публічний: будь-хто, хто знає адресу, може надіслати на нього «платіж підтверджено». Тому кожен запит треба перевіряти.
1. Підпис HMAC. Відправник обчислює HMAC від тіла запиту спільним секретом і передає в заголовку. Отримувач рахує те саме й порівнює:
$expected = 'sha256=' . hash_hmac('sha256', $request->getContent(), config('services.github.webhook_secret'));
abort_unless(hash_equals($expected, (string) $request->header('X-Hub-Signature-256')), 403);
- Підписується сире тіло запиту, байт у байт - не розібраний і знову серіалізований JSON.
- Порівняння - через
hash_equals, за сталий час.
2. Мітка часу проти повторів. Перехоплений правильний запит можна надіслати ще раз. Тому відправник включає в підпис мітку часу (як Stripe: t=...,v1=...), а отримувач відкидає запити, старші за кілька хвилин.
3. Ідемпотентна обробка. Відправники доставляють вебхуки «щонайменше раз» і повторюють їх при помилках чи таймаутах. Кожна подія має ID - зберігайте оброблені ID і пропускайте дублікати.
4. Швидка відповідь, обробка в черзі. Перевірити підпис, зберегти подію, поставити завдання в чергу й одразу відповісти 2xx. Довга обробка в самому запиті призводить до таймаутів і повторних доставок.
5. Не довіряти вмісту сліпо. Для критичних подій (оплата) безпечно перезапитати стан через API відправника: «чи справді платіж pi_123 успішний?».
Додатково: HTTPS обов'язковий; білий список IP відправника - лише як доповнення, не замість підпису; ротація секрету з періодом, коли приймаються обидва.
Докладніше в документації: Перевірка доставок вебхуків GitHub
Cache-Control каже клієнтам і проміжним кешам (CDN, проксі), чи можна зберігати відповідь і як довго:
Cache-Control: public, max-age=300 # будь-хто може кешувати 5 хвилин
Cache-Control: private, max-age=60 # лише браузер користувача
Cache-Control: no-store # не зберігати взагалі (персональні, чутливі дані)
Cache-Control: no-cache # зберігати, але перевіряти перед кожним використанням
Умовні запити й ETag. Сервер віддає «відбиток» версії ресурсу:
HTTP/1.1 200 OK
ETag: "a1b2c3"
Cache-Control: no-cache
Наступного разу клієнт питає «чи змінилося?»:
GET /products/42
If-None-Match: "a1b2c3"
Якщо ні - 304 Not Modified без тіла. Економиться трафік і серіалізація, хоча сам запит до сервера відбувається. Аналог за датою - Last-Modified / If-Modified-Since.
ETag для конкурентних змін: If-Match: "a1b2c3" при PUT/PATCH - оновити лише якщо ресурс не змінився з того часу, як клієнт його прочитав. Інакше 412 Precondition Failed. Це оптимістичне блокування на рівні HTTP, захист від загублених оновлень.
Нюанси:
- Персональні відповіді не можна позначати
public: CDN віддасть дані одного користувача іншому. Для відповідей, що залежать від токена, -privateабоno-store. Varyкаже кешу, від яких заголовків запиту залежить відповідь:Vary: Accept-Language,Vary: Authorization.- ETag має бути дешевим: якщо для нього треба зібрати всю відповідь, економиться лише трафік. Краще брати версію з бази (
updated_at, лічильник версії). - Інвалідація - найскладніше: короткий
max-ageплюсstale-while-revalidateчасто практичніші за спроби точно скидати кеш.
SSRF (Server-Side Request Forgery) - зловмисник змушує ваш сервер зробити запит туди, куди йому потрібно. Типові функції-мішені: імпорт за URL, попередній перегляд посилань, вебхуки (користувач вказує URL для сповіщень), завантаження аватара за посиланням, генерація PDF з HTML.
// вразливо
$image = Http::get($request->input('avatar_url'))->body();
Що можна дістати через ваш сервер:
- сервіси метаданих хмари:
http://169.254.169.254/- тимчасові облікові дані AWS/GCP/Azure. Класичний сценарій масштабних витоків; - внутрішні сервіси, недоступні ззовні: адмінки, бази з HTTP-інтерфейсом, Redis, панелі моніторингу,
localhost:8080; - сканування внутрішньої мережі за часом відповіді;
- файли через схеми
file://,gopher://- залежно від HTTP-клієнта.
Захист (кілька шарів, бо кожен окремо обходиться):
1. Дозволений список, а не заборонений. Якщо можна - лише відомі домени (інтеграції з конкретними сервісами).
2. Схема й порт: лише https (за потреби http), стандартні порти.
3. Перевірка IP після розв'язання DNS: заборонити приватні діапазони (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16), 127.0.0.0/8, link-local 169.254.0.0/16, IPv6-аналоги (::1, fc00::/7, fe80::/10).
Пастки, через які наївна перевірка не працює:
- DNS rebinding: домен при перевірці дає публічну адресу, а при реальному запиті - внутрішню. Перевіряти треба той IP, до якого реально підключаєтесь (зафіксувати розв'язану адресу для з'єднання);
- редиректи: перевірений URL повертає
302наhttp://169.254.169.254/. Редиректи вимкнути або перевіряти кожен крок; - альтернативні записи IP:
http://2130706433/,http://0x7f.1/,http://[::ffff:127.0.0.1]/- усе це127.0.0.1.
4. Мережева ізоляція: вихідні запити до користувацьких URL - через окремий проксі чи сервіс без доступу до внутрішньої мережі й метаданих. Найнадійніший шар.
5. Хмара: IMDSv2 на AWS (сесійний токен для метаданих) робить класичну атаку значно складнішою.
6. Обмеження відповіді: тайм-аути, максимальний розмір, перевірка типу вмісту - і не повертати клієнту сирі відповіді чи детальні помилки підключення (це дає зловмиснику «сліпе» сканування).
Для вебхуків з URL від користувача - ті самі правила плюс перевірка URL при збереженні й при кожній відправці.
Навіщо два різні токени:
- токен доступу - короткоживучий (хвилини), надсилається з кожним запитом. Якщо його вкрадуть - шкода обмежена часом;
- refresh-токен - довгоживучий (дні, тижні), використовується лише для отримання нового токена доступу і лише на ендпойнті авторизації.
Це компроміс між безпекою (короткі токени доступу) і зручністю (користувач не вводить пароль щогодини).
Чому refresh-токен - найцінніша мішень: хто його має, той може безстроково отримувати нові токени доступу. Тому RFC 9700 (найкращі практики безпеки OAuth 2.0) вимагає для публічних клієнтів (SPA, мобільні застосунки) або прив'язки токена до клієнта (DPoP, mTLS), або ротації.
Ротація refresh-токенів: кожне оновлення видає новий refresh-токен, а старий стає недійсним.
refresh_1 → (access_2, refresh_2) refresh_1 недійсний
refresh_2 → (access_3, refresh_3) refresh_2 недійсний
Виявлення повторного використання - головна перевага ротації:
- зловмисник викрав
refresh_2і скористався ним першим - отримавrefresh_3; - легітимний клієнт пізніше пред'являє
refresh_2- вже використаний; - сервер розуміє, що один з двох - зловмисник, і відкликає всю сім'ю токенів (усі, що походять від початкового входу). Обидва мусять увійти заново, а зловмисник втрачає доступ.
Для цього сервер зберігає ланцюжок: кожен refresh-токен знає свою «сім'ю» і чи був використаний.
Практичні деталі:
- паралельні запити: дві вкладки одночасно оновлюють токен з тим самим refresh-токеном - друга виглядає як «повторне використання». Рішення - короткий період допуску для того самого токена або серіалізація оновлення на клієнті;
- абсолютний термін життя сесії: навіть з ротацією ланцюжок не повинен жити вічно (наприклад, 30-90 днів до повторного входу);
- відкликання при зміні пароля і виході - усіх refresh-токенів користувача;
- зберігання: хеш у базі (як паролі); на клієнті - захищене сховище ОС для мобільних,
HttpOnly-cookie для вебу.
Для браузерних SPA взагалі варто зважити, чи потрібні токени: сесійна cookie з бекендом на тому ж домені (Sanctum SPA) чи патерн BFF (бекенд для фронтенду, що тримає токени на сервері) прибирають токени з браузера.
У Laravel Passport refresh-токени є з коробки; терміни задаються Passport::tokensExpireIn() і Passport::refreshTokensExpireIn().
Докладніше в документації: RFC 9700: найкращі практики безпеки OAuth 2.0
Коли ваш сервіс викликає інший (мікросервіс, партнерський API, внутрішній воркер), потрібно довести, який сервіс робить запит, і що запит не змінено по дорозі.
1. Статичний API-ключ / спільний секрет у заголовку - найпростіше:
Authorization: Bearer svc_billing_8f2a...
Мінуси: довгоживучий секрет, що зберігається в кількох місцях; викрадений ключ працює звідусіль; ротація болісна. Прийнятно для простих інтеграцій із ротацією й обмеженням за IP.
2. Підпис запиту (HMAC) - секрет не передається мережею, передається підпис:
X-Timestamp: 1760000000
X-Signature: hex(HMAC-SHA256(secret, method + path + timestamp + sha256(body)))
Отримувач обчислює підпис сам і порівнює у постійному часі (hash_equals). Мітка часу з коротким вікном (кілька хвилин) захищає від повторного відтворення. Так підписують вебхуки (Stripe, GitHub) і запити AWS (SigV4).
3. Взаємний TLS (mTLS) - обидві сторони пред'являють сертифікати:
- сервер перевіряє клієнтський сертифікат, виданий вашим внутрішнім центром сертифікації;
- ідентичність сервісу - у сертифікаті, а не в секреті в коді;
- RFC 8705 описує прив'язку OAuth-токенів до сертифіката: викрадений токен без приватного ключа клієнта непридатний.
Мінус - інфраструктура: власний CA, видача й ротація сертифікатів. Service mesh (Istio, Linkerd) роблять mTLS прозорим для застосунку.
4. Короткоживучі токени від центрального сервісу ідентифікації - OAuth 2.0 Client Credentials:
POST /oauth/token
grant_type=client_credentials&client_id=billing&client_secret=...&scope=orders:read
Сервіс отримує токен на хвилини з конкретними правами (scopes), отримувач перевіряє підпис і aud. У хмарах - ідентичності робочих навантажень (IAM-ролі, workload identity) без статичних секретів узагалі.
Як обрати:
- дві-три внутрішні інтеграції - підписані запити з ротацією секретів;
- багато сервісів, потрібні права й аудит - OAuth client credentials (у Laravel - Passport);
- мережа з нульовою довірою, високі вимоги - mTLS, часто разом із токенами.
Обов'язкове в будь-якому варіанті: найменші права для кожного сервісу, ротація секретів без простою (два дійсні ключі на час переходу), журнал викликів.
Версія в 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з датою вимкнення) і посилання на інструкцію з міграції; - моніторинг використання: скільки запитів і від яких клієнтів іде на стару версію - без цього неможливо вирішити, коли її вимикати;
- мобільні клієнти оновлюються роками - стара версія може жити довго, тож кожна нова версія - це додаткова підтримка.
Тестування: набори тестів для кожної підтримуваної версії - зміна спільної логіки не має ламати стару версію.
Альтернатива версіям - еволюція без ламання: додавати, але не змінювати; нові поля замість зміни старих; «розширювані» енуми. Багато команд обходяться однією версією роками, якщо дотримуються цих правил.
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 без жодної з його переваг.
Стандартні 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.
Час відповіді 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) - клієнт отримує лише потрібне. ВбудованийJsonApiResourceLaravel 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 (повільні ендпойнти й запити в продакшені), профайлер для гарячих точок у серіалізації. Оптимізація без вимірювань часто прискорює не те.
Проблема втраченого оновлення. Два менеджери відкрили одне замовлення. Перший змінив адресу доставки й зберіг. Другий, дивлячись на стару версію, змінив коментар і зберіг - і перезаписав адресу першого старим значенням. Ніхто не отримав помилки.
Оптимістичне блокування через умовні запити:
1. Сервер віддає версію ресурсу в ETag:
GET /api/orders/42
HTTP/1.1 200 OK
ETag: "v17"
2. Клієнт оновлює з умовою «лише якщо версія досі та сама»:
PATCH /api/orders/42
If-Match: "v17"
{"comment": "Подзвонити перед доставкою"}
3. Сервер перевіряє:
- версія збігається - оновлює, повертає новий
ETag: "v18"; - версія змінилася -
412 Precondition Failed, нічого не змінює. Клієнт перечитує ресурс, показує користувачу зміни й пропонує злити їх.
Якщо клієнт не надіслав If-Match для ресурсу, де конфлікти критичні, сервер може вимагати його: 428 Precondition Required (RFC 6585).
Реалізація в Laravel:
public function update(UpdateOrderRequest $request, Order $order): JsonResponse
{
$expected = trim($request->header('If-Match', ''), '"');
$updated = Order::whereKey($order->id)
->where('version', (int) ltrim($expected, 'v'))
->update([...$request->validated(), 'version' => DB::raw('version + 1')]);
abort_if($updated === 0, 412, 'Ресурс змінено іншим користувачем');
return OrderResource::make($order->fresh())
->response()
->header('ETag', '"v'.$order->fresh()->version.'"');
}
Ключове - перевірка й оновлення одним атомарним UPDATE ... WHERE version = ?. Варіант «прочитати версію, порівняти в PHP, потім записати» має ту саму гонитву, від якої ми захищаємося.
Джерело версії: окрема колонка version (лічильник), updated_at з мікросекундами або хеш вмісту. Лічильник найнадійніший: updated_at може збігтися при двох змінах в одну секунду.
Чим це краще за песимістичне блокування (заблокувати запис на час редагування): не потрібно тримати блокування між HTTP-запитами, немає «завислих» блокувань, коли користувач закрив вкладку. Конфлікти рідкісні - і обробляються лише тоді, коли справді стаються.
Слабкі ETag (W/"v17") для If-Match не підходять - порівняння має бути сильним.
Пакетний ендпойнт виконує багато однотипних операцій одним запитом: імпорт тисячі товарів, позначення 200 повідомлень прочитаними, оновлення цін.
Навіщо, якщо є HTTP/2: мультиплексування знімає витрати на з'єднання, але кожен окремий запит - це окремі автентифікація, валідація, транзакція, запис у журнал, ліміт частоти. Для тисяч операцій пакет у рази ефективніший і для клієнта, і для сервера.
Проєктування:
POST /api/products:batchCreate
{"items": [{"sku": "A-1", "name": "..."}, {"sku": "A-2", "name": "..."}]}
Головне рішення - атомарність:
1. Усе або нічого - одна транзакція; при будь-якій помилці нічого не застосовується:
HTTP/1.1 422 Unprocessable Content
{"errors": {"items.17.sku": ["Такий SKU вже існує"]}}
Просто для клієнта, але одна погана позиція блокує всі інші.
2. Часткове виконання - кожна позиція окремо, у відповіді результат для кожної:
HTTP/1.1 200 OK
{
"results": [
{"index": 0, "status": 201, "id": 501},
{"index": 1, "status": 422, "errors": {"sku": ["Такий SKU вже існує"]}}
]
}
Деякі API використовують 207 Multi-Status, але більшість обирає 200 з детальним тілом. Клієнт мусить перевіряти кожен результат - звичайна перевірка статусу відповіді нічого не скаже.
Яку модель обрано - має бути явно в документації і, бажано, в самому API (параметр atomic=true).
Обмеження й безпека:
- максимальний розмір пакета (100-1000 позицій) - перевищення дає 413/422, а не тайм-аут;
- ліміт частоти рахує позиції, а не запити - інакше пакети стають обходом ліміту;
- авторизація кожної позиції - пакет не повинен дозволяти змінити чужі записи серед своїх;
- ідемпотентність (
Idempotency-Key) - повтор пакета після обриву з'єднання не повинен створити дублікати.
Великі пакети - асинхронно: імпорт десятків тисяч позицій приймається як 202 Accepted з ресурсом статусу, обробляється чергою частинами (у Laravel - Bus::batch() з джобами).
Ефективність на сервері: масова вставка (insert() чи upsert() по частинах) замість створення моделей по одній - але тоді не спрацюють події й спостерігачі моделей, і це треба враховувати.
Докладніше в документації: Google AIP-233: пакетне створення
Звичайна відповідь Model::all()->toJson() будує весь JSON у пам'яті перед відправкою. Для експорту на сотні тисяч записів це гігабайти пам'яті й хвилини тиші, поки клієнт чекає першого байта.
Потокова відповідь відправляє дані частинами, щойно вони готові.
1. Потоковий JSON у Laravel:
return response()->streamJson([
'orders' => Order::query()->with('items')->lazyById(500),
]);
streamJson серіалізує й відправляє елементи по одному, а lazyById читає з бази порціями - пам'ять не росте з кількістю записів. Клієнт отримує звичайний валідний JSON.
2. NDJSON (JSON Lines) - один JSON-об'єкт на рядок:
{"id":1,"total":"150.00"}
{"id":2,"total":"80.50"}
return response()->stream(function () {
foreach (Order::query()->lazyById(500) as $order) {
echo json_encode(OrderResource::make($order)->resolve()), "\n";
flush();
}
}, 200, ['Content-Type' => 'application/x-ndjson']);
Перевага - клієнт може обробляти записи, не дочекавшись кінця: читати рядок, розбирати, обробляти. Звичайний JSON-масив неможливо розібрати стандартним JSON.parse до кінця відповіді.
3. Server-Sent Events - для подій, що з'являються з часом (прогрес, сповіщення, відповідь LLM по токенах):
return response()->eventStream(function () {
foreach ($this->generateAnswer() as $chunk) {
yield new StreamedEvent(event: 'chunk', data: $chunk);
}
});
Що враховувати в продакшені:
- буферизація по дорозі: Nginx (
proxy_buffering), PHP output buffering, стиснення, CDN можуть накопичувати дані й віддати все наприкінці. Для потокових маршрутів -X-Accel-Buffering: noчи окреме налаштування; - помилка посередині потоку: статус 200 уже відправлено. Клієнт має розпізнати обірвану відповідь (невалідний JSON, відсутній завершальний маркер), а сервер - логувати;
- воркер зайнятий весь час відправки - для PHP-FPM довгі потоки дорогі; тривалі експорти краще генерувати чергою у файл і віддавати посилання;
- тайм-аути проксі мають покривати тривалість передачі;
Content-Lengthневідомий - використовується chunked-передача (HTTP/1.1) чи кадри HTTP/2.
Альтернатива для великих експортів: асинхронна генерація файлу (CSV, NDJSON у сховищі S3/R2) і тимчасове підписане посилання на завантаження - сервер API не тримає з'єднання хвилинами.
Keyset-пагінація (курсорна, «seek method») продовжує вибірку з місця, де закінчилась попередня сторінка, умовою за ключем сортування, а не пропуском N рядків:
-- перша сторінка
SELECT id, title, published_at FROM posts
ORDER BY published_at DESC, id DESC
LIMIT 20;
-- наступна: після останнього елемента (published_at = '2026-09-30 10:00', id = 1057)
SELECT id, title, published_at FROM posts
WHERE (published_at, id) < ('2026-09-30 10:00', 1057)
ORDER BY published_at DESC, id DESC
LIMIT 20;
Ключові деталі:
- унікальність порядку: сортування лише за
published_atнеоднозначне - у кількох постів однаковий час, і на межі сторінок записи загубляться чи задвояться. До ключа додають унікальну колонку (id) як «розв'язувач»; - індекс під сортування:
(published_at DESC, id DESC)- тоді кожна сторінка - короткий пошук в індексі незалежно від глибини; - порівняння кортежів
(a, b) < (x, y)підтримують PostgreSQL і MySQL, але оптимізатор MySQL не завжди використовує для нього індекс - інколи надійніше розгорнута умоваa < x OR (a = x AND b < y); - NULL у ключі сортування ламає порівняння - такі колонки або виключають, або обробляють окремо;
- курсор непрозорий для клієнта: значення кодують (base64 JSON) і, бажано, підписують, щоб клієнт не підставляв довільні значення. Laravel
cursorPaginate()робить це сам.
Чому total дорогий. COUNT(*) з тими самими фільтрами - окремий запит, що проходить усі відповідні рядки. На таблиці з мільйонами записів і складними фільтрами він може коштувати більше, ніж сама сторінка, - і виконується на кожну сторінку. У PostgreSQL через MVCC навіть COUNT(*) без умов читає всю таблицю чи індекс.
Що робити замість точного total:
- не показувати - лише «далі» (
has_more), як у стрічках і більшості великих API; - рахувати до межі:
SELECT COUNT(*) FROM (SELECT 1 ... LIMIT 10001)і показувати «10 000+»; - приблизна кількість зі статистики бази (
reltuplesу PostgreSQL,EXPLAIN) - для оцінок «близько 2,3 млн»; - кешувати підрахунок на хвилини, якщо точність до запису не потрібна.
Обмеження keyset: немає переходу на довільну сторінку і зручного «сторінка 5 з 120». Для адмінок це інколи прийнятний компроміс з simplePaginate, для API з великими обсягами - стандарт.
Докладніше в документації: No Offset: пагінація без зміщення
OpenAPI 3.0 використовував власний діалект JSON Schema - «розширену підмножину»: частину ключових слів підтримував інакше, частину не підтримував, а частину додав від себе (nullable). Через це одну й ту саму схему не можна було напряму використати і в OpenAPI, і в бібліотеках валідації JSON Schema.
OpenAPI 3.1 зробив схеми повністю сумісними з JSON Schema 2020-12.
Основні зміни в схемах:
1. nullable прибрано - замість нього масив типів:
# 3.0
salary_from:
type: integer
nullable: true
# 3.1
salary_from:
type: [integer, 'null']
2. Приклади: example (одне значення) застаріло на користь examples - масиву, як у JSON Schema.
3. exclusiveMinimum / exclusiveMaximum - тепер числа, а не булеві прапорці.
4. $ref поруч з іншими ключовими словами - дозволено (у 3.0 сусідні ключі ігнорувалися), тож можна послатися на схему й додати опис.
5. Нові можливості JSON Schema: const, if/then/else, prependItems, $defs, unevaluatedProperties, оголошення діалекту через $schema.
6. Опис файлів: замість format: binary - contentMediaType і contentEncoding.
Інші зміни специфікації:
- вебхуки (
webhooks) - опис запитів, які API надсилає клієнтам; pathsстав необов'язковим - документ може описувати лише компоненти чи вебхуки;info.summary, ідентифікатор ліцензії SPDX.
Чому сумісність важлива на практиці:
- одна схема - кілька застосувань: та сама схема валідує запити на сервері, генерує типи (TypeScript, Zod), описує дані в документації й перевіряє відповіді в контрактних тестах - без перетворень і розбіжностей;
- екосистема JSON Schema (валідатори, генератори форм, редактори) працює з OpenAPI-схемами напряму;
- менше «дивних» відмінностей, через які інструменти по-різному трактували одну специфікацію.
Міграція 3.0 → 3.1 - не лише заміна номера версії: nullable, example, exclusiveMinimum треба переписати. І перевірити, що всі інструменти ланцюжка (генератори клієнтів, UI документації, шлюзи) підтримують 3.1 - деякі досі працюють коректно лише з 3.0.
OpenAPI 3.2 (2025) розширює 3.1 зворотно сумісно (потокові медіатипи, ієрархічні теги, метод QUERY) і не змінює модель схем.
Проблема: бекенд змінює відповідь API - перейменовує поле, змінює формат дати. Його тести зелені, тести фронтенду (з моками) теж зелені. А в продакшені мобільний застосунок падає. Ніхто не перевіряв, що реальний провайдер відповідає очікуванням реальних споживачів.
Контрактне тестування, кероване споживачем (consumer-driven contract testing), закриває саме цю прогалину. Найвідоміший інструмент - Pact.
Як це працює:
1. Споживач (фронтенд, мобільний застосунок, інший сервіс) у своїх тестах описує взаємодії:
provider
.uponReceiving('запит вакансії за id')
.withRequest({ method: 'GET', path: '/api/vacancies/42' })
.willRespondWith({
status: 200,
body: { data: { id: like(42), title: like('Laravel Developer'), salary_from: integer(3000) } },
});
Тест споживача виконується проти мок-сервера Pact і генерує контракт (JSON-файл) - перелік запитів і того, що споживач справді використовує з відповідей.
2. Контракт публікується (Pact Broker / PactFlow).
3. Провайдер (бекенд) у своєму CI перевіряє контракт: Pact відтворює запити з контракту проти справжнього застосунку й порівнює відповіді. Для станів («існує вакансія 42») провайдер готує дані.
4. Перед деплоєм перевіряється: чи сумісна ця версія провайдера з версіями споживачів, що зараз у продакшені (can-i-deploy).
Чим відрізняється від інших підходів:
- від перевірки за OpenAPI: специфікація описує, що провайдер може повернути. Контракт споживача - що він використовує. Видалення поля, яким ніхто не користується, не ламає контрактів - а зміна поля, яке читає мобільний застосунок, ламає, навіть якщо специфікацію оновили;
- від e2e-тестів: не потрібно піднімати всю систему разом - кожна сторона перевіряється окремо й швидко.
Коли варто:
- кілька незалежних команд і сервісів, що деплояться окремо;
- мобільні застосунки (старі версії живуть у користувачів місяцями);
- мікросервіси, що спілкуються HTTP чи повідомленнями.
Коли зайве: моноліт Laravel з фронтендом в одному репозиторії і спільним деплоєм - там простіше спільні типи (згенеровані з OpenAPI чи Wayfinder) і звичайні тести API.
Ціна: інфраструктура (брокер), дисципліна в обох командах, підготовка станів провайдера. Без активного використання споживачами контракти швидко стають формальністю.
Питання з реальних технічних співбесід - 100 питань у 7 темах, розібраних із відповідями. Нижче - розбивка за рівнями та темами, якщо хочете звузити підготовку.
Готуєтесь до співбесіди не просто так: зараз на сайті 146 відкритих вакансій Laravel і PHP. Переглянути вакансії