Питання на співбесіді: HTTP і продуктивність
Питання з реальних співбесід з відповідями: Laravel і PHP, бази даних, JavaScript і фронтенд, Git, Docker, API, безпека й архітектура. Тими самими темами, що й тести.
14 питань
Запит складається з рядка запиту, заголовків і (необов'язково) тіла:
POST /api/orders HTTP/1.1
Host: shop.example.com
Accept: application/json
Content-Type: application/json
Authorization: Bearer eyJhbGciOi...
Idempotency-Key: 8f14e45f-ceea-467f-a0d6-2a1e6c0c4f11
{"product_id": 42, "qty": 2}
- метод (
GET,POST,PATCH...) - що зробити; - шлях і рядок запиту - з яким ресурсом;
- заголовки - метадані: формат, автентифікація, кешування;
- тіло - дані (для
GETтіла зазвичай немає).
Відповідь - рядок статусу, заголовки, тіло:
HTTP/1.1 201 Created
Content-Type: application/json
Location: /api/orders/1057
Cache-Control: no-store
{"data": {"id": 1057, "status": "new"}}
Заголовки, які варто знати для API:
| Заголовок | Навіщо |
|---|---|
Content-Type |
формат тіла, яке надсилається |
Accept |
формат, який клієнт хоче отримати |
Authorization |
облікові дані (Bearer-токен) |
Location |
адреса створеного ресурсу (з 201) чи статусу операції (з 202) |
Cache-Control, ETag |
кешування й умовні запити |
Retry-After |
коли повторити (з 429, 503) |
X-Request-Id / traceparent |
наскрізний ідентифікатор для логів і трасування |
Типові помилки:
Content-Typeне відповідає тілу - сервер не розбере JSON, надісланий якtext/plain;- відсутній
Accept: application/jsonу запитах до Laravel - при помилці валідації замість JSON 422 прийде редирект на попередню сторінку; - статус 200 з
{"error": ...}у тілі - клієнти, проксі й моніторинг орієнтуються на код статусу, тож помилка має бути помилкою на рівні HTTP; - власні заголовки з префіксом
X-- застарілий звичай (RFC 6648); нові заголовки називають без нього.
Налагодження: curl -i показує заголовки відповіді, curl -v - і запиту; у браузері - вкладка Network.
Два заголовки, які часто плутають, описують різні напрямки:
Content-Type- формат тіла цього повідомлення. У запиті - що надсилає клієнт, у відповіді - що повертає сервер;Accept- формат, який клієнт готовий отримати у відповідь.
POST /api/reports
Content-Type: application/json
Accept: text/csv
{"from": "2026-09-01", "to": "2026-09-30"}
Клієнт надсилає JSON, а хоче отримати CSV.
Узгодження вмісту (content negotiation) - сервер обирає формат відповіді за Accept:
Accept: application/json;q=1.0, text/csv;q=0.5
q - вага переваги від 0 до 1. Сервер повертає найкращий з підтримуваних форматів, а якщо не підтримує жоден - 406 Not Acceptable. Якщо сервер не розуміє формат тіла запиту - 415 Unsupported Media Type.
Інші заголовки узгодження:
Accept-Language- мова (повідомлення помилок, перекладені поля);Accept-Encoding- стиснення (gzip,br,zstd);- у відповіді -
Vary: Accept, Accept-Language, щоб кеші й CDN зберігали окремі копії для різних варіантів.
Медіатипи для API:
application/json- основний;application/problem+json- помилки за RFC 9457;application/vnd.api+json- JSON:API;multipart/form-data- завантаження файлів;- власні «вендорні» типи з версією (
application/vnd.shop.v2+json) - один зі способів версіонування.
У Laravel:
$request->expectsJson()/wantsJson()перевіряютьAccept- від нього залежить, чи поверне Laravel помилки валідації й винятки як JSON;$request->isJson()- чи тіло запиту JSON (заContent-Type);$request->getAcceptableContentTypes()- список з урахуванням ваг.
Практичний підсумок: API-клієнти мають завжди надсилати Accept: application/json, а сервер - завжди виставляти правильний Content-Type відповіді з кодуванням (application/json для JSON вважається UTF-8 за специфікацією).
Пагінація зі зміщенням (offset) - номер сторінки:
GET /api/posts?page=3&per_page=20
SELECT * FROM posts ORDER BY created_at DESC LIMIT 20 OFFSET 40;
Курсорна пагінація - «продовжити після цього запису»:
GET /api/posts?cursor=eyJjcmVhdGVkX2F0IjoiMjAyNi0wOS0zMCJ9
SELECT * FROM posts WHERE created_at < '2026-09-30 12:00:00' ORDER BY created_at DESC LIMIT 20;
Курсор - закодоване значення ключа сортування останнього елемента попередньої сторінки.
Порівняння:
| Зміщення | Курсор | |
|---|---|---|
| перехід на довільну сторінку | так | ні, лише вперед/назад |
| загальна кількість сторінок | так (потрібен COUNT(*)) |
зазвичай ні |
| швидкість на далеких сторінках | падає: база читає й відкидає OFFSET рядків |
стабільна: пошук за індексом |
| нові записи під час гортання | дублікати й пропуски | коректно |
Проблема зміщення з даними, що змінюються: поки користувач на сторінці 2, з'явилося 5 нових постів. Сторінка 3 тепер починається на 5 записів «раніше» - частину постів він побачить двічі. Курсор прив'язаний до значення, а не до позиції, і такої проблеми не має.
У Laravel:
Post::latest()->paginate(20); // зміщення + COUNT(*), номери сторінок
Post::latest()->simplePaginate(20); // зміщення без COUNT(*), лише «далі/назад»
Post::latest()->cursorPaginate(20); // курсор
cursorPaginate вимагає, щоб сортування було за унікальною комбінацією колонок (зазвичай додають id як останній ключ) і щоб для неї був індекс.
Що обрати:
- адмінки, таблиці з переходом на сторінку N, невеликі обсяги - зміщення;
- стрічки, нескінченний скрол, мобільні застосунки, синхронізація й експорт великих обсягів - курсор.
У відповіді API варто віддавати посилання на наступну сторінку (links.next чи заголовок Link), а не змушувати клієнта збирати URL самостійно - тоді механізм пагінації можна змінити без зміни клієнтів.
HEAD - те саме, що GET, але без тіла відповіді: сервер повертає лише статус і заголовки.
HEAD /api/exports/2026-09.csv
HTTP/1.1 200 OK
Content-Type: text/csv
Content-Length: 48211337
Last-Modified: Wed, 01 Oct 2026 03:00:00 GMT
ETag: "a1b2c3"
Навіщо:
- дізнатися розмір файлу перед завантаженням (смуга прогресу, перевірка місця);
- перевірити, чи ресурс існує чи змінився (
ETag,Last-Modified) без передачі вмісту; - перевірка посилань - сканери битих посилань і моніторинг доступності.
Заголовки відповіді на HEAD мають бути такими самими, як для GET. У Laravel маршрути GET автоматично відповідають і на HEAD - фреймворк просто відкидає тіло.
OPTIONS - запит про можливості ресурсу: які методи підтримуються.
OPTIONS /api/orders/42
HTTP/1.1 204 No Content
Allow: GET, PATCH, DELETE
Головне застосування на практиці - попередній запит CORS (preflight). Перед «непростим» запитом з іншого джерела (методи PUT/PATCH/DELETE, заголовок Authorization, Content-Type: application/json) браузер сам надсилає OPTIONS:
OPTIONS /api/orders/42
Origin: https://app.example.com
Access-Control-Request-Method: PATCH
Access-Control-Request-Headers: authorization, content-type
Сервер відповідає дозволами (Access-Control-Allow-*), і лише потім іде справжній запит. У Laravel це робить middleware HandleCors з config/cors.php.
Що варто знати:
- обидва методи безпечні й ідемпотентні - не повинні змінювати стан;
- preflight - додатковий запит на кожен «непростий» запит з іншого джерела.
Access-Control-Max-Ageдозволяє браузеру кешувати дозвіл і не повторюватиOPTIONSщоразу; - автентифікація для
OPTIONS: браузер не надсилаєAuthorizationу preflight. Якщо middleware автентифікації відхиляєOPTIONSз 401, CORS-запити ламаються - preflight має оброблятися до автентифікації (як це й робить глобальнийHandleCors); 405 Method Not Allowedразом із заголовкомAllow- правильна відповідь на непідтримуваний метод.
JSON добре стискається: повторювані ключі й структура дають зменшення у 5-10 разів. Для мобільних клієнтів і великих списків це суттєва економія часу й трафіку.
Як це працює:
GET /api/products
Accept-Encoding: zstd, br, gzip
HTTP/1.1 200 OK
Content-Type: application/json
Content-Encoding: br
Vary: Accept-Encoding
- клієнт перелічує в
Accept-Encodingалгоритми, які розуміє; - сервер обирає один, стискає тіло й повідомляє про це в
Content-Encoding; - клієнт (браузер,
fetch, HTTP-клієнт) розпаковує автоматично.
Vary: Accept-Encoding обов'язковий для кешів і CDN - інакше стиснена копія може дістатися клієнту, що її не розуміє.
Алгоритми:
| Алгоритм | Особливість |
|---|---|
gzip |
підтримується всюди, швидкий, середнє стиснення |
br (Brotli) |
краще стиснення за gzip, особливо для тексту; підтримка в усіх сучасних браузерах |
zstd (Zstandard) |
дуже швидке стиснення й розпакування при хорошому коефіцієнті; підтримується в Chrome і Firefox, Safari - з 2025 року |
Сервер обирає найкращий з того, що підтримує клієнт, і зазвичай лишає gzip як запасний варіант.
Де вмикати: не в PHP, а на рівні вебсервера чи CDN - Nginx (gzip, модуль brotli), Caddy (encode zstd gzip), Cloudflare. Там стиснення ефективніше й не займає воркери PHP.
Що варто знати:
- малі відповіді не стискають: для тіла в кількасот байтів накладні витрати більші за виграш (типовий поріг - 1 КБ);
- вже стиснене (зображення, архіви, PDF) повторно не стискають;
- рівень стиснення - компроміс з процесором: для динамічних відповідей - середній рівень, максимальний - лише для статики, стисненої заздалегідь;
- BREACH: стиснення відповідей, що містять і секрет (CSRF-токен), і дані, підконтрольні атакувальнику, на HTTPS дає змогу вгадувати секрет за розміром відповіді. Для JSON API з токенами в заголовках, а не в тілі, ризик невеликий, але про атаку варто знати;
- тіло запиту клієнти зазвичай не стискають -
Content-Encodingу запиті сервер має підтримувати окремо.
Перевірка: curl -H 'Accept-Encoding: br' -I https://api.example.com/products - у відповіді має бути Content-Encoding.
Списки в 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: умовні зв'язки в ресурсах
Проблема втраченого оновлення. Два менеджери відкрили одне замовлення. Перший змінив адресу доставки й зберіг. Другий, дивлячись на стару версію, змінив коментар і зберіг - і перезаписав адресу першого старим значенням. Ніхто не отримав помилки.
Оптимістичне блокування через умовні запити:
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: пагінація без зміщення