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

Питання на співбесіді: Проєктування API

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

15 питань

  • POST - створити ресурс (або виконати дію). Сервер сам обирає адресу нового ресурсу: POST /orders → 201 Created з Location: /orders/42.
  • PUT - замінити ресурс цілком тим, що в тілі запиту. Поля, яких немає в тілі, вважаються видаленими чи скинутими.
  • PATCH - частково змінити ресурс: лише передані поля.
PUT /users/42
{"name": "Оля", "email": "olia@example.com", "phone": null}

PATCH /users/42
{"phone": "+380501234567"}

Ідемпотентність - ключова відмінність, про яку питають:

  • PUT і DELETE ідемпотентні: повторний однаковий запит лишає ресурс у тому самому стані. Їх можна безпечно повторити при обриві з'єднання.
  • POST не ідемпотентний: повтор створить друге замовлення.
  • PATCH - залежить від змісту: «встановити phone» ідемпотентний, «збільшити лічильник на 1» - ні.
  • GET, HEAD, OPTIONS - ще й безпечні: не змінюють стан узагалі.

На практиці:

  • Багато API використовують лише PATCH для оновлень, бо клієнту рідко потрібно надсилати весь ресурс.
  • Для створення з повторами (платежі) POST роблять ідемпотентним через заголовок Idempotency-Key.
  • Дії, що не вкладаються в CRUD (/orders/42/cancel), зазвичай оформлюють як POST на під-ресурс.

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

Успіх (2xx):

  • 200 OK - звичайна успішна відповідь з тілом.
  • 201 Created - ресурс створено; бажано з заголовком Location.
  • 202 Accepted - запит прийнято, обробка асинхронна (поставлено в чергу).
  • 204 No Content - успіх без тіла: видалення, оновлення без повернення даних.

Помилки клієнта (4xx):

  • 400 Bad Request - запит некоректний: зламаний JSON, неправильний формат.
  • 401 Unauthorized - не автентифіковано: немає токена чи він недійсний. Попри назву, це про «хто ви?».
  • 403 Forbidden - автентифіковано, але немає прав на цю дію.
  • 404 Not Found - ресурсу немає (або ви не маєте права знати, що він існує).
  • 409 Conflict - конфлікт стану: дублікат, застаріла версія при оптимістичному блокуванні.
  • 422 Unprocessable Content - синтаксис правильний, але дані не пройшли валідацію. Laravel так і відповідає на помилки валідації.
  • 429 Too Many Requests - перевищено ліміт запитів, з Retry-After.

Помилки сервера (5xx): 500 - непередбачена помилка; 502/503/504 - проблеми з upstream, перевантаження, обслуговування.

Типові помилки:

  • 200 OK з {"success": false, "error": "..."} - клієнти, проксі й моніторинг вважатимуть запит успішним.
  • 500 на помилку валідації - це не помилка сервера.
  • 403 для чужого ресурсу інколи розкриває, що він існує; тоді свідомо віддають 404.

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

REST (Representational State Transfer) - архітектурний стиль, описаний Роєм Філдінгом. Це не протокол і не формат, а набір обмежень:

  • ресурси й ідентифікатори: усе, з чим працює API, - ресурси з власними URL (/orders/42), а не «процедури»;
  • уніфікований інтерфейс: дії виражаються стандартними HTTP-методами (GET, POST, PUT, PATCH, DELETE) з їхньою семантикою;
  • представлення: клієнт отримує не сам ресурс, а його представлення (JSON, XML) - залежно від заголовка Accept;
  • без стану (stateless): кожен запит містить усе потрібне для обробки (зокрема автентифікацію). Сервер не пам'ятає «сесію розмови» між запитами;
  • кешованість: відповіді позначають, чи можна їх кешувати;
  • багаторівнева система: між клієнтом і сервером можуть бути проксі, CDN, балансувальники - і клієнт про це не знає.

«Просто JSON через HTTP» (стиль RPC) виглядає інакше:

POST /api/getOrder         {"id": 42}
POST /api/cancelOrder      {"id": 42}
POST /api/updateOrderStatus

RESTful:

GET    /api/orders/42
PATCH  /api/orders/42      {"status": "cancelled"}
DELETE /api/orders/42

Що дає REST на практиці:

  • передбачуваність: знаючи URL ресурсу й методи HTTP, легко здогадатися, як працювати з API;
  • інфраструктура HTTP працює «з коробки»: GET кешується проксі й браузерами, безпечні методи можна повторювати, коди стану зрозумілі моніторингу;
  • масштабування: відсутність стану на сервері дає змогу додавати сервери за балансувальником без «липких» сесій.

Реальність: більшість «REST API» не виконують усіх обмежень (зокрема гіпермедіа - HATEOAS), і це нормально. На співбесіді важливо розуміти суть - ресурси, семантика методів і кодів, відсутність стану, - а не догматичність.

RPC-стиль не заборонений: для дій, що не вкладаються в CRUD, чи для внутрішніх сервісів RPC (зокрема gRPC) інколи природніший. Головне - послідовність у межах одного API.

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

Безпечний метод не змінює стан на сервері - лише читає. Ідемпотентний метод - повторний виклик з тими самими даними дає той самий стан сервера, що й одиночний.

Метод Безпечний Ідемпотентний
GET, HEAD, OPTIONS так так
PUT ні так
DELETE ні так
POST ні ні
PATCH ні не гарантовано

Приклади:

  • PUT /users/7 {"name": "Оля"} - один раз чи п'ять, результат той самий: ім'я «Оля»;
  • DELETE /orders/42 - перший виклик видаляє, наступні нічого не змінюють (відповідь може бути 404, але стан однаковий);
  • POST /orders - кожен виклик створює нове замовлення;
  • PATCH {"balance": {"increment": 100}} - не ідемпотентний, а PATCH {"status": "paid"} - фактично ідемпотентний. Тому про PATCH кажуть «не гарантовано».

Ідемпотентність стосується стану, а не відповіді. Відповіді можуть відрізнятися (200 і потім 404 для DELETE), і updated_at може змінитися - важливо, що ефект для клієнта той самий.

Чому це важливо:

  • повтори при збоях мережі. Клієнт не отримав відповіді - він не знає, чи запит виконався. Ідемпотентний запит можна безпечно повторити. Неідемпотентний - ні: повтор POST /payments може списати гроші двічі. Для таких операцій потрібен ключ ідемпотентності;
  • проксі, браузери, бібліотеки автоматично повторюють і попередньо завантажують безпечні запити. Якщо GET /logout чи GET /orders/42/delete змінює стан, прийде «невидимий» користувач - пошуковий робот, попереднє завантаження посилань - і виконає дію;
  • кешування: відповіді на безпечні методи можна кешувати.

Типові помилки:

  • зміна стану в GET (лічильники, «відмітити прочитаним», видалення за посиланням);
  • PUT, реалізований як «додати до наявного» (тоді він не ідемпотентний);
  • POST для читання з великим тілом запиту - допустимо як виняток (складний пошук), але відповідь не кешується, і повтор не очевидно безпечний.

У Laravel CSRF-захист і так не перевіряє GET/HEAD/OPTIONS - ще одна причина не змінювати стан у них.

Докладніше в документації: Ідемпотентність

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

1. Стиль ключів - один на весь API: snake_case (природний для Laravel і баз даних) або camelCase (природний для JavaScript). Головне - не змішувати: created_at в одній відповіді й updatedAt в іншій.

2. Дати й час - ISO 8601 / RFC 3339 з часовим поясом:

{ "created_at": "2026-10-04T10:15:00+03:00", "paid_at": "2026-10-04T07:15:00Z" }

Не "04.10.2026", не мітка часу без пояснення, не локальний час без зміщення. «Дата без часу» (день народження) - окремий формат "2026-10-04".

3. Гроші - не числа з рухомою комою: мінімальні одиниці цілим числом ("amount": 125050 копійок) або рядок ("125.50") плюс явна валюта.

4. Ідентифікатори - рядки, якщо можуть бути великими: числа понад 2^53 JavaScript округлює. Для BIGINT чи Snowflake-ID безпечніше "id": "9007199254740993".

5. null чи відсутнє поле: вирішити й задокументувати. Поширений підхід - поле завжди є, null означає «немає значення». Відсутність поля - лише для свідомо необов'язкових чи прихованих правами.

6. Обгортка відповіді: { "data": ..., "meta": ..., "links": ... } для колекцій (пагінація в meta) - так роблять ресурси Laravel. Обгортка дає місце для метаданих без зміни структури даних.

7. Енуми - рядки, а не числа: "status": "paid" читається й не ламається при зміні порядку значень.

8. Булеві значення - true/false, а не 1/0 чи "yes".

9. Помилки - єдиний формат для всіх ендпойнтів (наприклад, Problem Details).

10. Без «магічних» значень: -1 замість null, порожній рядок замість відсутності.

Технічні деталі JSON:

  • кодування - UTF-8 (вимога RFC 8259 для обміну між системами);
  • порядок ключів в об'єкті не гарантується - клієнт не повинен на нього покладатися;
  • дублікати ключів - невизначена поведінка, різні парсери обирають різне значення.

У Laravel: касти дат у моделях ('paid_at' => 'datetime') серіалізуються в ISO 8601 UTC, API Resources керують ключами й форматом явно.

Докладніше в документації: RFC 8259: формат JSON

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

Problem Details (RFC 9457, замінив RFC 7807) - стандартний формат з типом application/problem+json:

{
  "type": "https://api.example.com/problems/insufficient-funds",
  "title": "Недостатньо коштів",
  "status": 422,
  "detail": "На рахунку 30 грн, а списання - 50 грн.",
  "instance": "/payments/8f3a",
  "balance": 3000
}
  • type - URI типу помилки, стабільний ідентифікатор для програмної обробки (бажано - з документацією за цією адресою).
  • title - короткий опис типу, однаковий для всіх його випадків.
  • status - HTTP-код (дублює заголовок для зручності).
  • detail - пояснення саме цього випадку.
  • instance - ідентифікатор конкретного випадку.
  • Можна додавати власні поля - як balance вище чи список помилок валідації за полями.

Принципи, незалежно від формату:

  • Правильний HTTP-код - формат тіла його не замінює.
  • Машиночитний код помилки (type чи code), а не лише текст: текст змінюють і перекладають, а клієнти на нього не мають покладатися.
  • Помилки валідації за полями, щоб клієнт підсвітив поля форми.
  • Жодних стек-трейсів, SQL і шляхів до файлів у відповіді на проді.
  • ID запиту в тілі чи заголовку - щоб знайти помилку в логах за зверненням клієнта.

Докладніше в документації: RFC 9457: Problem Details for HTTP APIs

Основні правила:

  • Іменники, а не дієслова: дія задається HTTP-методом. GET /orders, POST /orders, DELETE /orders/42 - а не /getOrders, /createOrder.
  • Множина для колекцій: /orders і /orders/42 - узгоджено для всього API.
  • Вкладеність для відношень, але неглибока: /users/42/orders - так; /users/42/orders/7/items/3/reviews - ні. Ресурс з власним ID краще адресувати напряму: /order-items/3.
  • Нижній регістр і дефіси: /payment-methods, а не /paymentMethods чи /payment_methods.
  • Фільтрація, сортування, пагінація - у query-параметрах: /orders?status=paid&sort=-created_at&page[size]=20.
  • Стабільні ідентифікатори в URL: ID чи UUID, а не назви, які можуть змінитися.

Дії, що не вкладаються в CRUD:

  • як під-ресурс стану: POST /orders/42/cancellation;
  • як дія-під-ресурс: POST /orders/42/cancel - прагматично й зрозуміло, і так роблять великі API (Stripe, GitHub);
  • головне - узгодженість у межах API.

Відповіді:

  • Узгоджене іменування полів (snake_case чи camelCase - одне на весь API).
  • Дати - в ISO 8601 з часовим поясом.
  • Гроші - цілими числами в мінімальних одиницях або рядками, з валютою.
  • Посилання на пов'язані ресурси чи їхні ID, а не дублювання цілих об'єктів без потреби.

Найважливіше - передбачуваність: розробник, побачивши два ендпоінти, має вгадати третій. Опис в OpenAPI допомагає тримати цю узгодженість і генерувати клієнти.

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

Вкладений ресурс відображає відношення «належить до» в URL:

GET  /posts/42/comments        # коментарі конкретного поста
POST /posts/42/comments        # додати коментар до поста

Коли вкладеність доречна:

  • дочірній ресурс не має сенсу без батька (коментар без поста, позиція без замовлення);
  • створення - URL одразу задає батька, і його не треба передавати в тілі;
  • колекція в контексті: «замовлення цього клієнта» - природний запит.

Де зупинитися:

1. Не глибше одного рівня. /users/7/orders/42/items/3/discounts/1 - важко читати, і всі проміжні ідентифікатори доводиться знати й перевіряти. Якщо в дочірнього ресурсу є власний унікальний id, на ньому можна працювати напряму:

GET    /posts/42/comments      # колекція - вкладена
GET    /comments/15            # окремий коментар - плаский
PATCH  /comments/15
DELETE /comments/15

Це неглибока вкладеність (shallow nesting): вкладені лише ті маршрути, де батько потрібен (колекція й створення). У Laravel - Route::resource('posts.comments', CommentController::class)->shallow().

2. Не для фільтрації за довільними полями. /users/7/orders - добре, але /status/paid/orders - ні: це фільтр, а не ієрархія, - GET /orders?status=paid.

3. Не для зв'язків «багато-до-багатьох» без явної власності: /tags/5/posts і /posts/42/tags - обидва варіанти доречні як точки входу, але сам зв'язок - окремий ресурс.

Безпека - головна пастка вкладених URL:

GET /posts/42/comments/15

Перевірити треба не лише, що користувач має доступ до поста 42, а й що коментар 15 належить посту 42. Інакше зловмисник підставить пост, до якого має доступ, і чужий коментар. У Laravel для цього - scopeBindings():

Route::get('/posts/{post}/comments/{comment}', ...)->scopeBindings();

Тоді коментар шукається через $post->comments(), і чужий дасть 404.

Консистентність: якщо вже обрано схему (вкладені колекції + плаский доступ до елементів), - дотримуватися її для всіх ресурсів API.

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

Реальні API мають операції, які погано виражаються через «створити, прочитати, оновити, видалити»: скасувати замовлення, опублікувати статтю, надіслати рахунок, перерахувати знижку.

Варіант 1 - зміна стану через PATCH:

PATCH /orders/42
{"status": "cancelled"}

Підходить, коли дія справді лише змінює поле. Проблеми починаються, коли:

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

Тоді PATCH з полем status ховає важливу бізнес-операцію за «оновленням поля», і валідація переходів розмазується.

Варіант 2 - дія як підресурс (найпоширеніший):

POST /orders/42/cancel        {"reason": "Помилка в адресі"}
POST /articles/7/publish
POST /invoices/15/send

Дієслово в URL - свідомий виняток із «лише іменників»: операція явна, має власні параметри, права й журнал аудиту.

Варіант 3 - дія як ресурс-іменник:

POST /orders/42/cancellations     # створити «скасування»
POST /refunds                     {"order_id": 42, "amount": 1000}

Корисно, коли у дії є власний життєвий цикл (повернення коштів може бути в обробці, відхиленим, завершеним) і її треба переглядати потім.

Google API (AIP-136) використовує синтаксис з двокрапкою: POST /orders/42:cancel. Він чітко відділяє дію від ієрархії ресурсів, але в Laravel-проєктах частіше бачать варіант 2.

Правила для власних дій:

  • метод POST - дія неідемпотентна чи має побічні ефекти. Для ідемпотентних (повторне «опублікувати» нічого не змінює) варто це задокументувати;
  • відповідь - оновлений ресурс (200 з тілом) або 202 Accepted, якщо дія асинхронна;
  • недопустимий перехід - 409 Conflict (замовлення вже відправлено) з поясненням у тілі;
  • права - окремо для кожної дії (can:cancel,order), а не загальне «можна оновлювати».

Головне - послідовність: один стиль для всіх дій в API.

Докладніше в документації: Google AIP-136: власні методи

Варіант 1 - multipart/form-data через сервер API:

POST /api/documents
Content-Type: multipart/form-data; boundary=...

title=Договір
file=<бінарні дані>
$request->validate(['file' => ['required', 'file', 'mimes:pdf', 'max:10240']]);
$path = $request->file('file')->store('documents', 's3');

Просто, валідація й збереження в одному запиті. Але весь файл проходить через сервер застосунку: займає процес PHP на весь час завантаження, обмежений upload_max_filesize/post_max_size і тайм-аутами, а для великих файлів - ще й проксі (client_max_body_size у Nginx).

Варіант 2 - підписаний URL і пряме завантаження в сховище (S3, R2):

  1. клієнт просить дозвіл: POST /api/uploads {"filename": "contract.pdf", "size": 52428800};
  2. сервер перевіряє права й параметри і повертає тимчасовий підписаний URL:
['url' => $url, 'headers' => $headers] = Storage::disk('s3')
    ->temporaryUploadUrl("uploads/{$id}.pdf", now()->plus(minutes: 5));
  1. клієнт завантажує файл напряму в сховище (PUT $url);
  2. клієнт повідомляє API: POST /api/documents {"upload_id": "..."} - сервер перевіряє, що файл справді з'явився, його розмір і тип, і створює запис.

Переваги прямого завантаження: сервер застосунку не тримає з'єднання, немає лімітів PHP, сховище масштабується саме, можна завантажувати частинами (multipart upload S3) з продовженням після збою.

Що обов'язково для безпеки:

  • короткий термін дії URL і фіксований шлях - клієнт не обирає, куди писати;
  • перевірка після завантаження: розмір і тип - не довіряти тому, що клієнт заявив у кроці 1. Підозрілі файли - в карантин чи на антивірусну перевірку;
  • очищення завантажених, але не підтверджених файлів (правило життєвого циклу бакета);
  • приватний бакет і видача файлів теж через підписані URL.

Варіант 3 - base64 у JSON: зручно для дуже малих файлів (аватар-мініатюра), але +33% до розміру й весь файл у пам'яті - для решти погано.

Як обрати: невеликі файли (до кількох мегабайтів) - multipart; великі, багато одночасних завантажень, мобільні клієнти з нестабільною мережею - пряме завантаження.

Докладніше в документації: Laravel: тимчасові URL для завантаження

Проблема: клієнт відправив POST /payments, а відповідь не дійшла - таймаут, обрив мережі. Чи пройшов платіж? Повторити запит небезпечно (подвійне списання), не повторити - теж (платіж може не пройти).

Рішення: клієнт генерує унікальний ключ (UUID) для кожної логічної операції і передає його в заголовку. Повтор з тим самим ключем не виконує операцію вдруге, а повертає збережену відповідь першого виконання.

POST /payments
Idempotency-Key: 4f0f9c2e-8b1a-4c3e-9d5f-2a7b6c8d9e01
{"amount": 5000, "currency": "UAH"}

Реалізація на сервері:

  1. Отримати ключ і атомарно зарезервувати його (унікальний індекс у таблиці чи SET NX у Redis) разом з ID користувача.
  2. Якщо ключ новий - виконати операцію й зберегти статус і тіло відповіді.
  3. Якщо ключ уже є й операція завершена - повернути збережену відповідь.
  4. Якщо ключ є, але операція ще виконується (паралельний повтор) - 409 Conflict, щоб клієнт повторив пізніше.
  5. Якщо той самий ключ прийшов з іншим тілом запиту - 422: це помилка клієнта, ключ використано повторно для іншої операції.

Деталі, про які питають:

  • Ключ прив'язують до користувача, щоб чужий ключ не дав доступ до чужої відповіді.
  • Термін зберігання ключів - зазвичай 24 години.
  • Зберігати відповідь потрібно в тій самій транзакції, що й результат операції, інакше падіння між ними знову дасть подвійне виконання.
  • Помилки валідації (4xx) зазвичай зберігають, а тимчасові збої (5xx) - ні, щоб повтор мав шанс пройти.

Саме так працюють Stripe і більшість платіжних API. IETF стандартизує заголовок Idempotency-Key в окремій специфікації.

Докладніше в документації: Чернетка IETF: Idempotency-Key

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

Сумісні зміни (можна будь-коли):

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

Несумісні (ламаючі): видалити чи перейменувати поле, змінити тип чи формат, зробити поле обов'язковим, змінити зміст коду відповіді, змінити поведінку за замовчуванням.

Версіонування для ламаючих змін:

  • в URL - /v1/orders, /v2/orders: найпростіше й найпомітніше;
  • в заголовку - Accept: application/vnd.example.v2+json або власний заголовок;
  • датою - як Stripe (Stripe-Version: 2025-03-31): кожен клієнт закріплений на версії API на момент інтеграції, а сервер перетворює відповіді для старих версій.

Плавне виведення старого:

  1. Оголосити застарілість у документації й журналі змін.
  2. Додати заголовки: Deprecation (RFC 9745) - що ресурс застарів, Sunset (RFC 8594) - дата, після якої він перестане працювати, і Link на документацію з міграцією.
  3. Моніторити використання старої версії за клієнтами й писати тим, хто ще на ній.
  4. Вимикати лише після дати й коли трафік зійшов нанівець.

Найкращий спосіб уникнути ламаючих змін - продумане проєктування: обгортки-об'єкти замість голих масивів у відповідях (до них можна додати поля), ідентифікатори-рядки, явні формати дат і грошей.

Докладніше в документації: RFC 9745: заголовок Deprecation

HATEOAS (Hypermedia as the Engine of Application State) - обмеження REST, за яким клієнт не знає URL наперед, а знаходить наступні можливі дії в посиланнях з відповідей, як людина переходить за посиланнями на сайті.

{
  "id": 42,
  "status": "pending",
  "total": 1250,
  "_links": {
    "self":   { "href": "/orders/42" },
    "pay":    { "href": "/orders/42/payments", "method": "POST" },
    "cancel": { "href": "/orders/42/cancel", "method": "POST" }
  }
}

Після оплати посилання pay зникне, а з'явиться, наприклад, invoice. Клієнт не вирішує сам, чи можна скасувати замовлення, - він бачить, чи є посилання cancel.

Рой Філдінг наполягав: API без гіпермедіа - не REST. Тому більшість «REST API» в його термінах насправді «HTTP API».

Що обіцяє HATEOAS:

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

Чому на практиці його використовують рідко:

  • клієнти все одно знають домен: мобільний застосунок «знає», що в замовлення є оплата, і має для неї окремий екран. Він не будує інтерфейс динамічно з посилань;
  • URL і так стабільні й задокументовані в OpenAPI; генеровані клієнти працюють зі статичними шляхами;
  • накладні витрати: розмір відповідей, складність серверу, слабка підтримка інструментами;
  • немає єдиного стандарту формату посилань (HAL, JSON:API, Siren, JSON-LD).

Що з HATEOAS корисно взяти навіть без повної реалізації:

  • посилання пагінації (next, prev) - ресурси Laravel додають їх автоматично. Курсорну пагінацію інакше й не реалізувати зручно;
  • прапорці дозволених дій у відповіді - практичний компроміс: "can": {"cancel": true, "refund": false} - клієнт не повторює правила авторизації;
  • Location після створення і посилання на асинхронний статус (202 + URL задачі).

JSON:API (Laravel 13 має вбудований JsonApiResource) включає links у стандарт - це найпоширеніший спосіб отримати частину переваг гіпермедіа без власного формату.

Докладніше в документації: Рой Філдінг: REST API мають керуватися гіпертекстом

Ці типи найчастіше «ламаються» на межі між системами - і помилки проявляються не одразу, а в окремих часових поясах, на певних сумах чи при великих ID.

Дата й час - мить у часі:

  • формат RFC 3339 (профіль ISO 8601) з поясом: 2026-10-04T07:15:00Z чи 2026-10-04T10:15:00+03:00;
  • сервер зберігає й віддає в UTC, а в місцевий час перетворює клієнт для показу;
  • ніколи без поясу: 2026-10-04 10:15:00 різні клієнти зрозуміють по-різному.

Дата без часу (день народження, дата події) - окремий тип "2026-10-04". Перетворення на мить у часі зсуне її на день у деяких поясах.

Локальний час із поясом користувача (зустріч о 10:00 за Києвом наступного вівторка) - зберігають локальний час + ідентифікатор поясу (Europe/Kyiv), а не зміщення: правила переходу на літній час змінюються, і +03:00 для майбутньої дати може стати неправильним.

Тривалість - ISO 8601 (PT15M) або явні одиниці в назві поля (duration_seconds).

Гроші:

  • не float: 0.1 + 0.2 - класика. JSON-число парсери перетворюють на double;
  • мінімальні одиниці цілим числом ("amount": 125050 - копійки) або рядок ("125.50");
  • валюта завжди поруч ("currency": "UAH", ISO 4217) - бо кількість знаків після коми різна (у японської єни - нуль);
  • округлення - на сервері, за явними правилами; клієнт не перераховує суми сам.

Великі ідентифікатори:

  • JavaScript точно представляє цілі лише до 2^53 - 1. BIGINT з бази, Snowflake-ID, ідентифікатори Twitter - рядком: "id": "1844712345678901234";
  • для UUID - рядок у канонічному вигляді.

Числа з високою точністю (координати, курси, наукові дані) - рядок або явно задокументована точність.

Перелічення - рядки-коди ("status": "paid"), а не числа: порядок і значення не залежать від внутрішнього enum.

Що варто зафіксувати в документації API: формат кожного такого поля з прикладом. Помилки «в нас усе працює, у клієнта з Нью-Йорка - ні» майже завжди про недописаний формат дат.

У Laravel: касти datetime серіалізуються в ISO 8601 UTC; decimal:2 - рядком; для великих BIGINT у ресурсі - явне (string) $this->id.

Докладніше в документації: RFC 3339: дата й час в Інтернеті

Більшість бізнес-сутностей мають життєвий цикл: замовлення (нове → оплачене → відправлене → доставлене / скасоване), стаття (чернетка → на модерації → опублікована), заявка, платіж. API має відображати цей цикл явно.

1. Поле стану - перелічення з документованими значеннями:

{ "id": 42, "state": "paid", "paid_at": "2026-10-04T07:15:00Z" }

Не набір булевих прапорців (is_paid, is_shipped, is_cancelled) - вони допускають неможливі комбінації.

2. Стан змінюють не напряму, а через дії:

PATCH /orders/42 {"state": "shipped"}          # погано: обходить правила
POST  /orders/42/ship {"tracking": "UA123"}    # добре: явний перехід з даними

Google AIP-216 радить робити поле стану лише для читання (output only) і змінювати його власними методами. Тоді:

  • кожен перехід має свої параметри (трек-номер при відправці, причина при скасуванні);
  • свої права (скасувати може клієнт, відправити - лише склад);
  • свої побічні ефекти (лист, повернення коштів) - у явному місці коду.

3. Недопустимий перехід - явна помилка:

POST /orders/42/cancel
409 Conflict
{ "type": "/problems/invalid-state-transition", "title": "Замовлення вже відправлено", "current_state": "shipped" }

4. Клієнт має знати, що дозволено зараз:

{ "state": "paid", "allowed_actions": ["ship", "refund"] }

Інтерфейс показує кнопки за цим списком - логіку переходів не дублюють на клієнтах.

5. Історія переходів - окремий ресурс (GET /orders/42/events): хто, коли, з якого стану в який. Потрібна для підтримки, аудиту й спорів.

6. Конкурентні переходи: два запити «скасувати» й «відправити» одночасно. На сервері - перевірка поточного стану атомарно (UPDATE ... WHERE state = 'paid' чи блокування рядка), а в API - оптимістичне блокування через If-Match/версію.

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

На бекенді Laravel це зручно оформити енумом стану з методом canTransitionTo() або пакетом станів (spatie/laravel-model-states), а дії - окремими класами-actions, на які спираються контролери.

Докладніше в документації: Google AIP-216: стани