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

Питання на співбесіді з API

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

100 питань

GraphQL викликає резолвер для кожного поля кожного об'єкта. Запит

{
  posts(first: 50) {
    title
    author { name }
  }
}

виконає один запит за постами, а потім резолвер author - 50 разів, по одному на пост. Класичне N+1, тільки його не видно в коді: кожен резолвер окремо виглядає невинно.

DataLoader - патерн (і бібліотека з такою назвою від авторів GraphQL), що збирає звернення в пакет:

  1. резолвери не запитують базу одразу, а кажуть завантажувачу «мені потрібен автор 7», «мені потрібен автор 12»;
  2. завантажувач накопичує ключі протягом поточного «такту» виконання;
  3. потім робить один запит за всіма ключами (WHERE id IN (7, 12, ...)) і роздає результати резолверам;
  4. в межах запиту кешує вже завантажені значення - той самий автор не завантажиться двічі.

Головна вимога до функції пакетного завантаження: повернути результати в тому самому порядку, що й ключі, і з відповідною кількістю елементів (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) - тест, що перевіряє: кількість запитів не росте разом із кількістю елементів у списку.

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

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.

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

Одержувач вебхука може бути недоступним, повільним чи повертати помилку - доставка мусить це пережити.

Архітектура відправлення:

  1. подія фіксується в базі в тій самій транзакції, що й зміна даних (запис «вебхук до відправки»). Інакше при падінні після коміту подія загубиться - патерн outbox;
  2. джоба в черзі надсилає запит;
  3. результат кожної спроби записується в журнал доставок.
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 має лише збирати й адаптувати дані, а не вирішувати, наприклад, чи можна оформити замовлення.

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

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, асинхронні події - черги чи брокери повідомлень.

Докладніше в документації: Вступ до 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) для масових інтеграцій - щоб сотні джоб після збою провайдера не вдарили по ньому одночасно.

Докладніше в документації: HTTP-клієнт: повторні спроби

Послідовні запити складають свої затримки: три запити по 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.

Докладніше в документації: 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 з чіткими типами на вході й виході.

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

Багато 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 - лише потрібні права.

Докладніше в документації: OAuth 2.0: Client Credentials

Проблема: клієнт відправив 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: стани

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

Рівні
Junior 35 Middle 35 Senior 30

Готуєтесь до співбесіди не просто так: зараз на сайті 146 відкритих вакансій Laravel і PHP. Переглянути вакансії