Питання на співбесіді з API
Питання з реальних співбесід з відповідями: Laravel і PHP, бази даних, JavaScript і фронтенд, Git, Docker, API, безпека й архітектура. Тими самими темами, що й тести.
100 питань
GraphQL викликає резолвер для кожного поля кожного об'єкта. Запит
{
posts(first: 50) {
title
author { name }
}
}
виконає один запит за постами, а потім резолвер author - 50 разів, по одному на пост. Класичне N+1, тільки його не видно в коді: кожен резолвер окремо виглядає невинно.
DataLoader - патерн (і бібліотека з такою назвою від авторів GraphQL), що збирає звернення в пакет:
- резолвери не запитують базу одразу, а кажуть завантажувачу «мені потрібен автор 7», «мені потрібен автор 12»;
- завантажувач накопичує ключі протягом поточного «такту» виконання;
- потім робить один запит за всіма ключами (
WHERE id IN (7, 12, ...)) і роздає результати резолверам; - в межах запиту кешує вже завантажені значення - той самий автор не завантажиться двічі.
Головна вимога до функції пакетного завантаження: повернути результати в тому самому порядку, що й ключі, і з відповідною кількістю елементів (null для відсутніх).
У Laravel з Lighthouse це зроблено за вас для зв'язків:
type Post {
title: String!
author: User! @belongsTo
comments: [Comment!]! @hasMany
}
Директиви зв'язків Lighthouse пакетують запити до бази - зв'язок для 50 постів завантажиться одним запитом. Для даних, що не є зв'язками Eloquent (зовнішній сервіс, обчислення), Lighthouse дає змогу писати власні пакетні завантажувачі.
Типові пастки:
- власний резолвер з
->find()усередині поля обходить пакетування й повертає N+1; - вкладені списки з пагінацією в кожному елементі (
posts { comments(first: 5) }) складні для пакетування - «перші 5 коментарів кожного поста» не виражається простимIN, потрібні віконні функції чи окремі стратегії; - кеш завантажувача - на запит, не глобальний: інакше користувачі побачать чужі дані.
Як помітити: лічильник SQL-запитів на один GraphQL-запит (Telescope, Debugbar, Pulse) - тест, що перевіряє: кількість запитів не росте разом із кількістю елементів у списку.
Lighthouse - пакет, у якому GraphQL API описується схемою-першою (schema-first): ви пишете файл graphql/schema.graphql, а директиви пов'язують поля з моделями Eloquent.
composer require nuwave/lighthouse
php artisan vendor:publish --tag=lighthouse-schema
type Query {
posts: [Post!]! @paginate(defaultCount: 20) @orderBy(column: "created_at", direction: DESC)
post(id: ID! @eq): Post @find
me: User @auth
}
type Mutation {
createPost(input: CreatePostInput! @spread): Post!
@guard
@canModel(ability: "create")
@create
}
input CreatePostInput {
title: String! @rules(apply: ["required", "max:255"])
body: String!
}
type Post {
id: ID!
title: String!
author: User! @belongsTo
}
Що тут відбувається без жодного PHP-коду:
@paginate- пагінація з типами для сторінок;@find,@all- вибірка моделей;@eq- умоваwhere;@belongsTo,@hasMany- зв'язки з пакетним завантаженням (без N+1);@guard- автентифікація (Sanctum чи інший guard),@canModel(і решта сімейства@can*:@canFind,@canQuery...) - політики Laravel. Стара універсальна@canу Lighthouse 6 позначена застарілою;@rules- звичайні правила валідації Laravel;@create,@update,@delete- мутації над моделями.
Власна логіка - резолвер-клас:
final class PublishPost
{
public function __invoke(null $_, array $args): Post
{
$post = Post::findOrFail($args['id']);
Gate::authorize('publish', $post);
$post->publish();
return $post;
}
}
publishPost(id: ID!): Post! @field(resolver: "App\\GraphQL\\Mutations\\PublishPost")
Що налаштувати одразу:
- безпеку в
config/lighthouse.php:max_query_depth,max_query_complexity, вимкнення інтроспекції в продакшені - за замовчуванням обмеження вимкнені; - кешування схеми в продакшені (
php artisan lighthouse:cache); - авторизацію на кожному полі, що віддає чутливі дані, а не лише на запитах верхнього рівня;
- тести: трейт
MakesGraphQLRequestsз методомgraphQL()і перевіркою відповіді.
Чим відрізняється від REST-контролерів: схема - єдине джерело правди і документація одночасно; клієнти (Apollo, urql) генерують з неї типи TypeScript.
Одержувач вебхука може бути недоступним, повільним чи повертати помилку - доставка мусить це пережити.
Архітектура відправлення:
- подія фіксується в базі в тій самій транзакції, що й зміна даних (запис «вебхук до відправки»). Інакше при падінні після коміту подія загубиться - патерн outbox;
- джоба в черзі надсилає запит;
- результат кожної спроби записується в журнал доставок.
final class DeliverWebhook implements ShouldQueue
{
public $tries = 8;
public function __construct(public WebhookDelivery $delivery) {}
public function backoff(): array
{
return [10, 60, 300, 1800, 3600, 7200, 21600]; // секунди: до кількох годин
}
public function handle(): void
{
$response = Http::timeout(5)
->withHeaders($this->delivery->signatureHeaders())
->post($this->delivery->endpoint->url, $this->delivery->payload);
$this->delivery->recordAttempt($response->status());
if ($response->failed()) {
throw new WebhookDeliveryFailed($response->status());
}
}
}
Правила повторів:
- наростаюча затримка (експоненційна, з випадковим розкидом) - щоб не «добити» сервер одержувача, який відновлюється, і щоб тисячі вебхуків не повторювалися синхронно;
- повторювати мережеві помилки, тайм-аути, 5xx і 429 (з урахуванням
Retry-After); 4xx на кшталт 400, 404, 410 - зазвичай ні: повтор не допоможе; - загальний горизонт - години чи дні (Stripe повторює до трьох днів), після чого доставка позначається невдалою.
Журнал доставок у кабінеті клієнта: подія, URL, час, код відповіді, фрагмент тіла відповіді, кількість спроб, кнопка «надіслати повторно». Найкорисніша функція для підтримки - більшість звернень «вебхук не прийшов» вирішуються переглядом журналу.
Захист від «мертвих» одержувачів: якщо ендпойнт падає днями, його автоматично вимикають і повідомляють власника листом - інакше черга забивається безнадійними спробами.
Ізоляція: вебхуки - в окремій черзі з власними воркерами, щоб повільні одержувачі не затримували решту фонових задач застосунку.
Готові інструменти: spatie/laravel-webhook-server (черга, підпис, повтори, події WebhookCallFailedEvent), або зовнішні сервіси доставки вебхуків, якщо обсяги великі.
Важливо для одержувачів: доставка «щонайменше один раз» означає дублікати - документуйте, що обробка має бути ідемпотентною за id події.
Докладніше в документації: Stripe: автоматичні повтори вебхуків
Backend for Frontend - окремий серверний шар під конкретний клієнт: веб-застосунок, мобільний застосунок, адмінка. Кожен BFF збирає дані з внутрішніх сервісів у формі, зручній саме своєму інтерфейсу.
веб-застосунок → BFF для вебу ↘
мобільний застосунок → BFF для мобільних → сервіси замовлень, користувачів, каталогу
партнерське API → публічне API ↗
Яку проблему розв'язує. Одне загальне API для всіх клієнтів поступово обростає компромісами:
- мобільному застосунку на повільній мережі потрібні компактні відповіді, а вебу - більше даних за раз;
- екран збирається з п'яти запитів до різних сервісів - на мобільній мережі це повільно;
- зміна, потрібна одному клієнту, ламає іншого;
- команда фронтенду чекає на команду API заради кожного нового поля.
Що робить BFF:
- агрегує: один запит від клієнта - кілька паралельних запитів до сервісів - одна відповідь під екран;
- адаптує формат: лише потрібні поля, зручна структура, локалізовані підписи;
- тримає автентифікацію браузера: BFF працює з сесійними cookie (
HttpOnly), а токени до внутрішніх сервісів не потрапляють у браузер. Це рекомендований підхід для SPA з OAuth; - належить команді фронтенду - вона змінює його у власному темпі.
Ви, можливо, вже маєте BFF:
- Next.js з серверними компонентами й Route Handlers - фактично BFF для React-застосунку;
- Laravel з Inertia - контролери готують props саме для сторінок Vue/React;
- GraphQL частково вирішує ту саму проблему іншим способом - клієнт сам вибирає поля.
Коли BFF виправданий:
- кілька клієнтів з суттєво різними потребами;
- мікросервісна архітектура, де клієнтові інакше довелося б знати про багато сервісів;
- окремі команди фронтенду, яким потрібна незалежність.
Коли зайвий: один клієнт і моноліт - сам моноліт уже є «бекендом для фронтенду».
Ризики: дублювання логіки між кількома BFF (спільне виносять у сервіси), і бізнес-правила, що непомітно переїжджають у BFF. BFF має лише збирати й адаптувати дані, а не вирішувати, наприклад, чи можна оформити замовлення.
gRPC - фреймворк віддалених викликів процедур: клієнт викликає метод на сервері так, ніби це локальна функція. Контракт описується у файлі .proto мовою Protocol Buffers:
syntax = "proto3";
package billing.v1;
service InvoiceService {
rpc GetInvoice (GetInvoiceRequest) returns (Invoice);
rpc StreamPayments (StreamPaymentsRequest) returns (stream Payment);
}
message GetInvoiceRequest {
int64 id = 1;
}
message Invoice {
int64 id = 1;
string number = 2;
int64 total_cents = 3;
repeated LineItem items = 4;
}
З .proto генеруються клієнти й серверні заготовки для десятків мов - контракт однаковий для Go, Java, PHP, Node.js.
Чому його обирають для внутрішнього зв'язку сервісів:
- компактність і швидкість: Protocol Buffers - бінарний формат, повідомлення в рази менші за JSON і швидше розбираються. Імена полів не передаються - лише номери;
- HTTP/2: одне з'єднання для багатьох паралельних викликів, стиснення заголовків;
- стримінг: від сервера, від клієнта і двобічний - потоки подій, великі вивантаження без пагінації;
- суворий контракт: типи перевіряються при генерації коду, а не в рантаймі;
- дедлайни й скасування вбудовані в протокол: клієнт задає, скільки готовий чекати, і скасування поширюється ланцюжком викликів.
Обмеження:
- браузер не говорить gRPC напряму - потрібен gRPC-Web з проксі (Envoy) чи шлюз, що перетворює на REST/JSON;
- налагодження: бінарні повідомлення не прочитаєш у DevTools - потрібні
grpcurl, Postman з підтримкою gRPC; - PHP-FPM погано підходить для gRPC-сервера (довгі з'єднання, HTTP/2) - сервери пишуть на Go, Java, Node.js, а з PHP частіше виступають клієнтом (розширення
grpc) або використовують RoadRunner; - інфраструктура: балансувальники мають розуміти HTTP/2 і довгоживучі з'єднання.
Відповіді й помилки: у gRPC власні коди стану (NOT_FOUND, UNAVAILABLE, DEADLINE_EXCEEDED, PERMISSION_DENIED) замість HTTP-кодів.
Типова картина: публічне API - REST/JSON, внутрішні виклики між сервісами - gRPC, асинхронні події - черги чи брокери повідомлень.
Метод retry повторює запит при помилці:
Http::retry(3, 200)->get($url); // 3 спроби, 200 мс між ними
Http::retry([100, 500, 2000])->get($url); // затримки по черзі
Http::retry(4, fn (int $attempt) => 2 ** $attempt * 100)->get($url); // експоненційно
Сигнатура: retry(array|int $times, Closure|int $sleepMilliseconds = 0, ?callable $when = null, bool $throw = true).
Третій аргумент - коли повторювати. За замовчуванням повтор іде при будь-якій помилці - і клієнтській (4xx), і серверній. Це рідко правильно:
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Http\Client\RequestException;
Http::retry(3, 300, function (Throwable $exception, PendingRequest $request) {
if ($exception instanceof ConnectionException) {
return true; // мережа, тайм-аут
}
return $exception instanceof RequestException
&& in_array($exception->response->status(), [429, 500, 502, 503, 504], true);
})->get($url);
Що повторювати: мережеві збої, тайм-аути, 502/503/504, 429 (краще з урахуванням Retry-After).
Що НЕ повторювати:
- 400, 404, 422 - повтор дасть ту саму відповідь;
- 401/403 - хіба що після оновлення токена: колбек може змінити запит (
$request->withToken($newToken)) і повернутиtrue; - неідемпотентні операції (
POSTна створення платежу, відправка SMS) без ключа ідемпотентності: таймаут не означає, що запит не дійшов - повтор може списати гроші двічі.
throw: false - після всіх спроб повернути останню відповідь замість винятку. Але ConnectionException кидається все одно, якщо всі спроби впали через з'єднання.
Повтори в запиті користувача vs у черзі:
- синхронно - лише короткі повтори (1-2 спроби, сотні мілісекунд): користувач чекає, а процес PHP зайнятий;
- у джобі - довгі затримки краще віддати черзі (
$tries,backoff()): джоба звільняє воркер між спробами, а не спить уusleep.
Не множити повтори. Http::retry(3) всередині джоби з $tries = 5 - до 15 запитів на одну операцію, а з повторами на рівні проксі чи SDK - ще більше. Кожен рівень, що повторює, - вирішується свідомо.
Випадковий розкид затримки (jitter) для масових інтеграцій - щоб сотні джоб після збою провайдера не вдарили по ньому одночасно.
Послідовні запити складають свої затримки: три запити по 400 мс - 1,2 секунди. Якщо вони незалежні, їх можна виконати одночасно - і чекати лише найповільніший.
use Illuminate\Http\Client\Pool;
use Illuminate\Support\Facades\Http;
$responses = Http::pool(fn (Pool $pool) => [
$pool->as('rates')->get('https://api.example.com/rates'),
$pool->as('stock')->withToken($token)->get('https://warehouse.example.com/stock/42'),
$pool->as('delivery')->timeout(5)->get('https://delivery.example.com/estimate', ['city' => 'Київ']),
]);
$rates = $responses['rates']->json();
Особливості пулу:
- заголовки й налаштування - на кожному запиті в пулі:
Http::withHeaders(...)->pool(...)не застосується до запитів усередині; as()дає доступ до відповідей за назвою, без нього - за індексом у порядку додавання;- помилки не кидаються: невдалий запит повертає відповідь з кодом помилки, а збій з'єднання - об'єкт винятку на місці відповіді. Перевіряйте кожен результат:
$response instanceof Response && $response->successful().
Обмеження паралельності - якщо запитів багато:
$responses = Http::pool(fn (Pool $pool) => collect($skus)
->map(fn ($sku) => $pool->as($sku)->get("https://api.example.com/products/{$sku}"))
->all(), concurrency: 5);
Без обмеження сотня запитів піде одночасно - і провайдер відповість 429 або заблокує ключ.
Http::batch() - схожий механізм з колбеками before, progress, then, catch, finally - зручно для відстеження прогресу й обробки окремих збоїв.
Коли пул доречний:
- кілька незалежних джерел для однієї сторінки чи розрахунку (курси, залишки, доставка);
- масове оновлення даних у джобі - з
concurrencyпід ліміт провайдера.
Чого пул не вирішує:
- сумарне навантаження: паралельність прискорює ваш процес, але не зменшує кількість запитів до провайдера;
- ліміти частоти - для тисяч запитів потрібна черга з
RateLimited, а не один гігантський пул; - довгі операції в запиті користувача - навіть паралельно, три запити по 5 секунд - це 5 секунд очікування. Такі речі - у фоні.
Альтернатива поза HTTP-клієнтом - Concurrency::run() для паралельного виконання довільних замикань (окремі процеси), коли треба розпаралелити не лише HTTP.
Провайдери обмежують частоту запитів: «60 на хвилину», «10 на секунду на ключ». Десять воркерів, що паралельно синхронізують тисячі записів, легко перевищать ліміт - і отримають 429 або тимчасове блокування ключа.
1. Обмежувач частоти для джоб - RateLimiter::for() + middleware RateLimited:
// AppServiceProvider::boot()
RateLimiter::for('crm', fn (object $job) => Limit::perMinute(50));
use Illuminate\Queue\Middleware\RateLimited;
final class SyncContactToCrm implements ShouldQueue
{
public function middleware(): array
{
return [new RateLimited('crm')];
}
public function retryUntil(): DateTime
{
return now()->plus(hours: 6);
}
}
Джоба, що перевищила ліміт, повертається в чергу з затримкою. Важливо: повернення збільшує лічильник спроб - тому замість малого $tries використовують retryUntil() (обмеження за часом).
Ліміт спільний для всіх воркерів і серверів, бо зберігається в кеші (для Redis є RateLimitedWithRedis).
2. Реакція на 429 від провайдера. Навіть з власним обмежувачем провайдер може відповісти 429 (інші клієнти того самого ключа, інша методика підрахунку). Поважайте Retry-After:
$response = Http::crm()->post('/contacts', $payload);
if ($response->status() === 429) {
$this->release((int) $response->header('Retry-After') ?: 60);
return;
}
3. ThrottlesExceptions - якщо провайдер почав масово падати, перестати його смикати:
return [(new ThrottlesExceptions(10, 5 * 60))->by('crm-api')->backoff(1)];
Після 10 винятків поспіль джоби з тим самим ключем чекають 5 хвилин. by() об'єднує різні джоби, що ходять до одного провайдера, у спільний «кошик».
4. Окрема черга з обмеженою кількістю воркерів - найпростіший грубий обмежувач: черга crm з двома процесами у Horizon фізично не зробить більше двох запитів одночасно.
5. Зменшити кількість запитів: пакетні ендпойнти провайдера (оновити 100 записів одним запитом), кешування довідників, ShouldBeUnique - щоб не ставити в чергу синхронізацію того самого запису десять разів поспіль.
Моніторинг: кількість 429 і довжина черги інтеграцій - перші сигнали, що ліміт треба переглянути (чи купувати вищий тариф API).
Коли виклики до одного API розкидані по контролерах і джобах (Http::withToken(...)->get('https://...') у двадцяти місцях), будь-яка зміна - нова версія API, інший заголовок, логування - перетворюється на пошук по всьому проєкту. Налаштування треба зібрати в одному місці.
Рівень 1 - макрос з базовими налаштуваннями:
// AppServiceProvider::boot()
Http::macro('novaPoshta', fn () => Http::baseUrl(config('services.nova_poshta.url'))
->acceptJson()
->connectTimeout(3)
->timeout(10)
->retry(2, 300, fn ($e) => $e instanceof ConnectionException));
// використання
$response = Http::novaPoshta()->post('/', $payload);
Добре для невеликої кількості викликів: спільні адреса, тайм-аути, автентифікація.
Рівень 2 - клас-клієнт з методами предметної області:
final class NovaPoshtaClient
{
public function __construct(private readonly string $apiKey) {}
public function trackParcel(string $number): ParcelStatus
{
$response = Http::novaPoshta()
->post('/', [
'apiKey' => $this->apiKey,
'modelName' => 'TrackingDocument',
'calledMethod' => 'getStatusDocuments',
'methodProperties' => ['Documents' => [['DocumentNumber' => $number]]],
])
->throw();
return ParcelStatus::fromApi($response->json('data.0'));
}
}
Реєстрація в контейнері ($this->app->singleton(...) з ключем з конфігурації) і впровадження в конструктори.
Що дає клас:
- решта коду не знає про HTTP: він викликає
trackParcel()і отримує DTO, а не масив з дивними ключами провайдера; - один місце для перетворення зовнішнього формату у ваші типи - зміни провайдера не розходяться по проєкту;
- легко підмінити в тестах - фейковою реалізацією інтерфейсу або
Http::fake(); - місце для кешування, логування, обробки специфічних помилок провайдера.
Рівень 3 - SDK. Якщо провайдер має офіційний PHP SDK, часто розумно ним скористатися - але через власний клас-обгортку, щоб SDK не проник у весь код. Для власних SDK до великих API - бібліотека Saloon з конекторами, запитами, DTO й тестовими моками.
Правило: кількість шарів - за складністю інтеграції. Один виклик - макросу досить; ключова інтеграція з десятками методів (платежі, CRM, склад) - клас-клієнт або SDK з чіткими типами на вході й виході.
Багато API вимагають не статичний ключ, а короткоживучий токен доступу, який треба періодично отримувати й оновлювати.
Client Credentials - сервер звертається до API від імені свого застосунку, без участі користувача (інтеграції між системами):
final class WarehouseToken
{
public function get(): string
{
return Cache::remember('warehouse:access_token', now()->plus(minutes: 50), function () {
$response = Http::asForm()
->post(config('services.warehouse.token_url'), [
'grant_type' => 'client_credentials',
'client_id' => config('services.warehouse.client_id'),
'client_secret' => config('services.warehouse.client_secret'),
'scope' => 'stock:read',
])
->throw();
return $response->json('access_token');
});
}
}
Ключові моменти:
- кешувати токен на термін трохи менший за
expires_in(запас на годинники й тривалі запити). Запит нового токена на кожен виклик API - зайві затримки і можливе блокування за частотою; - конкурентне оновлення: коли токен закінчився, десяток воркерів одночасно підуть за новим. Захист - блокування (
Cache::lock('warehouse:token')) чиCache::flexibleдля фонового оновлення; - повтор при 401: токен могли відкликати раніше терміну - скинути кеш, отримати новий і повторити запит один раз:
Http::withToken($token->get())
->retry(2, 0, function ($exception, PendingRequest $request) use ($token) {
if (! $exception instanceof RequestException || $exception->response->status() !== 401) {
return false;
}
$request->withToken($token->refresh());
return true;
})
->get($url);
Токени від імені користувача (Authorization Code: доступ до Google Calendar користувача, його GitHub):
- зберігати access token і refresh token у базі, зашифрованими (каст
encrypted); - оновлювати access token через refresh token, коли закінчився термін, і зберігати новий refresh token, якщо провайдер його видав (багато провайдерів ротують refresh token при кожному використанні - старий перестає працювати);
- обробляти відкликання доступу (користувач відключив застосунок):
invalid_grant- позначити інтеграцію неактивною й попросити підключити знову; - у Laravel вхід через провайдерів і отримання токенів - Socialite.
Секрети клієнта (client_secret) - у .env/менеджері секретів, ніколи у фронтенді. Мінімальні scope - лише потрібні права.
Проблема: клієнт відправив POST /payments, а відповідь не дійшла - таймаут, обрив мережі. Чи пройшов платіж? Повторити запит небезпечно (подвійне списання), не повторити - теж (платіж може не пройти).
Рішення: клієнт генерує унікальний ключ (UUID) для кожної логічної операції і передає його в заголовку. Повтор з тим самим ключем не виконує операцію вдруге, а повертає збережену відповідь першого виконання.
POST /payments
Idempotency-Key: 4f0f9c2e-8b1a-4c3e-9d5f-2a7b6c8d9e01
{"amount": 5000, "currency": "UAH"}
Реалізація на сервері:
- Отримати ключ і атомарно зарезервувати його (унікальний індекс у таблиці чи
SET NXу Redis) разом з ID користувача. - Якщо ключ новий - виконати операцію й зберегти статус і тіло відповіді.
- Якщо ключ уже є й операція завершена - повернути збережену відповідь.
- Якщо ключ є, але операція ще виконується (паралельний повтор) -
409 Conflict, щоб клієнт повторив пізніше. - Якщо той самий ключ прийшов з іншим тілом запиту -
422: це помилка клієнта, ключ використано повторно для іншої операції.
Деталі, про які питають:
- Ключ прив'язують до користувача, щоб чужий ключ не дав доступ до чужої відповіді.
- Термін зберігання ключів - зазвичай 24 години.
- Зберігати відповідь потрібно в тій самій транзакції, що й результат операції, інакше падіння між ними знову дасть подвійне виконання.
- Помилки валідації (4xx) зазвичай зберігають, а тимчасові збої (5xx) - ні, щоб повтор мав шанс пройти.
Саме так працюють Stripe і більшість платіжних API. IETF стандартизує заголовок Idempotency-Key в окремій специфікації.
Опублікований API - контракт з клієнтами, яких ви не контролюєте: мобільні застосунки старих версій, інтеграції партнерів. Їх не можна оновити одночасно з сервером.
Сумісні зміни (можна будь-коли):
- додати новий ендпоінт;
- додати необов'язкове поле в запит;
- додати поле у відповідь (клієнти мають ігнорувати невідомі поля - це варто прописати в документації);
- додати нове значення в перелік - обережно: клієнт зі строгою перевіркою enum може зламатися.
Несумісні (ламаючі): видалити чи перейменувати поле, змінити тип чи формат, зробити поле обов'язковим, змінити зміст коду відповіді, змінити поведінку за замовчуванням.
Версіонування для ламаючих змін:
- в URL -
/v1/orders,/v2/orders: найпростіше й найпомітніше; - в заголовку -
Accept: application/vnd.example.v2+jsonабо власний заголовок; - датою - як Stripe (
Stripe-Version: 2025-03-31): кожен клієнт закріплений на версії API на момент інтеграції, а сервер перетворює відповіді для старих версій.
Плавне виведення старого:
- Оголосити застарілість у документації й журналі змін.
- Додати заголовки:
Deprecation(RFC 9745) - що ресурс застарів,Sunset(RFC 8594) - дата, після якої він перестане працювати, іLinkна документацію з міграцією. - Моніторити використання старої версії за клієнтами й писати тим, хто ще на ній.
- Вимикати лише після дати й коли трафік зійшов нанівець.
Найкращий спосіб уникнути ламаючих змін - продумане проєктування: обгортки-об'єкти замість голих масивів у відповідях (до них можна додати поля), ідентифікатори-рядки, явні формати дат і грошей.
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.
Більшість бізнес-сутностей мають життєвий цикл: замовлення (нове → оплачене → відправлене → доставлене / скасоване), стаття (чернетка → на модерації → опублікована), заявка, платіж. 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, на які спираються контролери.
Питання з реальних технічних співбесід - 100 питань у 7 темах, розібраних із відповідями. Нижче - розбивка за рівнями та темами, якщо хочете звузити підготовку.
Готуєтесь до співбесіди не просто так: зараз на сайті 146 відкритих вакансій Laravel і PHP. Переглянути вакансії