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

Питання на співбесіді: 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.

Докладніше в документації: Огляд HTTP

Два заголовки, які часто плутають, описують різні напрямки:

  • 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 самостійно - тоді механізм пагінації можна змінити без зміни клієнтів.

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

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 - правильна відповідь на непідтримуваний метод.

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

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
  1. клієнт перелічує в Accept-Encoding алгоритми, які розуміє;
  2. сервер обирає один, стискає тіло й повідомляє про це в Content-Encoding;
  3. клієнт (браузер, 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.

Докладніше в документації: Стиснення в HTTP

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

GET /api/exports/7f3a

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

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

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

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

У Laravel:

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

    GenerateExport::dispatch($export);

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

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

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

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

Що важливо:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Оптимістичне блокування через умовні запити:

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 не підходять - порівняння має бути сильним.

Докладніше в документації: Заголовок 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 не тримає з'єднання хвилинами.

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

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: пагінація без зміщення