API: питання на співбесіді рівня Senior
Питання з реальних співбесід з відповідями: Laravel і PHP, бази даних, JavaScript і фронтенд, Git, Docker, API, безпека й архітектура. Тими самими темами, що й тести.
30 питань
Проблема: клієнт відправив 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, на які спираються контролери.
Ендпоінт вебхука публічний: будь-хто, хто знає адресу, може надіслати на нього «платіж підтверджено». Тому кожен запит треба перевіряти.
1. Підпис HMAC. Відправник обчислює HMAC від тіла запиту спільним секретом і передає в заголовку. Отримувач рахує те саме й порівнює:
$expected = 'sha256=' . hash_hmac('sha256', $request->getContent(), config('services.github.webhook_secret'));
abort_unless(hash_equals($expected, (string) $request->header('X-Hub-Signature-256')), 403);
- Підписується сире тіло запиту, байт у байт - не розібраний і знову серіалізований JSON.
- Порівняння - через
hash_equals, за сталий час.
2. Мітка часу проти повторів. Перехоплений правильний запит можна надіслати ще раз. Тому відправник включає в підпис мітку часу (як Stripe: t=...,v1=...), а отримувач відкидає запити, старші за кілька хвилин.
3. Ідемпотентна обробка. Відправники доставляють вебхуки «щонайменше раз» і повторюють їх при помилках чи таймаутах. Кожна подія має ID - зберігайте оброблені ID і пропускайте дублікати.
4. Швидка відповідь, обробка в черзі. Перевірити підпис, зберегти подію, поставити завдання в чергу й одразу відповісти 2xx. Довга обробка в самому запиті призводить до таймаутів і повторних доставок.
5. Не довіряти вмісту сліпо. Для критичних подій (оплата) безпечно перезапитати стан через API відправника: «чи справді платіж pi_123 успішний?».
Додатково: HTTPS обов'язковий; білий список IP відправника - лише як доповнення, не замість підпису; ротація секрету з періодом, коли приймаються обидва.
Докладніше в документації: Перевірка доставок вебхуків GitHub
Cache-Control каже клієнтам і проміжним кешам (CDN, проксі), чи можна зберігати відповідь і як довго:
Cache-Control: public, max-age=300 # будь-хто може кешувати 5 хвилин
Cache-Control: private, max-age=60 # лише браузер користувача
Cache-Control: no-store # не зберігати взагалі (персональні, чутливі дані)
Cache-Control: no-cache # зберігати, але перевіряти перед кожним використанням
Умовні запити й ETag. Сервер віддає «відбиток» версії ресурсу:
HTTP/1.1 200 OK
ETag: "a1b2c3"
Cache-Control: no-cache
Наступного разу клієнт питає «чи змінилося?»:
GET /products/42
If-None-Match: "a1b2c3"
Якщо ні - 304 Not Modified без тіла. Економиться трафік і серіалізація, хоча сам запит до сервера відбувається. Аналог за датою - Last-Modified / If-Modified-Since.
ETag для конкурентних змін: If-Match: "a1b2c3" при PUT/PATCH - оновити лише якщо ресурс не змінився з того часу, як клієнт його прочитав. Інакше 412 Precondition Failed. Це оптимістичне блокування на рівні HTTP, захист від загублених оновлень.
Нюанси:
- Персональні відповіді не можна позначати
public: CDN віддасть дані одного користувача іншому. Для відповідей, що залежать від токена, -privateабоno-store. Varyкаже кешу, від яких заголовків запиту залежить відповідь:Vary: Accept-Language,Vary: Authorization.- ETag має бути дешевим: якщо для нього треба зібрати всю відповідь, економиться лише трафік. Краще брати версію з бази (
updated_at, лічильник версії). - Інвалідація - найскладніше: короткий
max-ageплюсstale-while-revalidateчасто практичніші за спроби точно скидати кеш.
SSRF (Server-Side Request Forgery) - зловмисник змушує ваш сервер зробити запит туди, куди йому потрібно. Типові функції-мішені: імпорт за URL, попередній перегляд посилань, вебхуки (користувач вказує URL для сповіщень), завантаження аватара за посиланням, генерація PDF з HTML.
// вразливо
$image = Http::get($request->input('avatar_url'))->body();
Що можна дістати через ваш сервер:
- сервіси метаданих хмари:
http://169.254.169.254/- тимчасові облікові дані AWS/GCP/Azure. Класичний сценарій масштабних витоків; - внутрішні сервіси, недоступні ззовні: адмінки, бази з HTTP-інтерфейсом, Redis, панелі моніторингу,
localhost:8080; - сканування внутрішньої мережі за часом відповіді;
- файли через схеми
file://,gopher://- залежно від HTTP-клієнта.
Захист (кілька шарів, бо кожен окремо обходиться):
1. Дозволений список, а не заборонений. Якщо можна - лише відомі домени (інтеграції з конкретними сервісами).
2. Схема й порт: лише https (за потреби http), стандартні порти.
3. Перевірка IP після розв'язання DNS: заборонити приватні діапазони (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16), 127.0.0.0/8, link-local 169.254.0.0/16, IPv6-аналоги (::1, fc00::/7, fe80::/10).
Пастки, через які наївна перевірка не працює:
- DNS rebinding: домен при перевірці дає публічну адресу, а при реальному запиті - внутрішню. Перевіряти треба той IP, до якого реально підключаєтесь (зафіксувати розв'язану адресу для з'єднання);
- редиректи: перевірений URL повертає
302наhttp://169.254.169.254/. Редиректи вимкнути або перевіряти кожен крок; - альтернативні записи IP:
http://2130706433/,http://0x7f.1/,http://[::ffff:127.0.0.1]/- усе це127.0.0.1.
4. Мережева ізоляція: вихідні запити до користувацьких URL - через окремий проксі чи сервіс без доступу до внутрішньої мережі й метаданих. Найнадійніший шар.
5. Хмара: IMDSv2 на AWS (сесійний токен для метаданих) робить класичну атаку значно складнішою.
6. Обмеження відповіді: тайм-аути, максимальний розмір, перевірка типу вмісту - і не повертати клієнту сирі відповіді чи детальні помилки підключення (це дає зловмиснику «сліпе» сканування).
Для вебхуків з URL від користувача - ті самі правила плюс перевірка URL при збереженні й при кожній відправці.
Навіщо два різні токени:
- токен доступу - короткоживучий (хвилини), надсилається з кожним запитом. Якщо його вкрадуть - шкода обмежена часом;
- refresh-токен - довгоживучий (дні, тижні), використовується лише для отримання нового токена доступу і лише на ендпойнті авторизації.
Це компроміс між безпекою (короткі токени доступу) і зручністю (користувач не вводить пароль щогодини).
Чому refresh-токен - найцінніша мішень: хто його має, той може безстроково отримувати нові токени доступу. Тому RFC 9700 (найкращі практики безпеки OAuth 2.0) вимагає для публічних клієнтів (SPA, мобільні застосунки) або прив'язки токена до клієнта (DPoP, mTLS), або ротації.
Ротація refresh-токенів: кожне оновлення видає новий refresh-токен, а старий стає недійсним.
refresh_1 → (access_2, refresh_2) refresh_1 недійсний
refresh_2 → (access_3, refresh_3) refresh_2 недійсний
Виявлення повторного використання - головна перевага ротації:
- зловмисник викрав
refresh_2і скористався ним першим - отримавrefresh_3; - легітимний клієнт пізніше пред'являє
refresh_2- вже використаний; - сервер розуміє, що один з двох - зловмисник, і відкликає всю сім'ю токенів (усі, що походять від початкового входу). Обидва мусять увійти заново, а зловмисник втрачає доступ.
Для цього сервер зберігає ланцюжок: кожен refresh-токен знає свою «сім'ю» і чи був використаний.
Практичні деталі:
- паралельні запити: дві вкладки одночасно оновлюють токен з тим самим refresh-токеном - друга виглядає як «повторне використання». Рішення - короткий період допуску для того самого токена або серіалізація оновлення на клієнті;
- абсолютний термін життя сесії: навіть з ротацією ланцюжок не повинен жити вічно (наприклад, 30-90 днів до повторного входу);
- відкликання при зміні пароля і виході - усіх refresh-токенів користувача;
- зберігання: хеш у базі (як паролі); на клієнті - захищене сховище ОС для мобільних,
HttpOnly-cookie для вебу.
Для браузерних SPA взагалі варто зважити, чи потрібні токени: сесійна cookie з бекендом на тому ж домені (Sanctum SPA) чи патерн BFF (бекенд для фронтенду, що тримає токени на сервері) прибирають токени з браузера.
У Laravel Passport refresh-токени є з коробки; терміни задаються Passport::tokensExpireIn() і Passport::refreshTokensExpireIn().
Докладніше в документації: RFC 9700: найкращі практики безпеки OAuth 2.0
Коли ваш сервіс викликає інший (мікросервіс, партнерський API, внутрішній воркер), потрібно довести, який сервіс робить запит, і що запит не змінено по дорозі.
1. Статичний API-ключ / спільний секрет у заголовку - найпростіше:
Authorization: Bearer svc_billing_8f2a...
Мінуси: довгоживучий секрет, що зберігається в кількох місцях; викрадений ключ працює звідусіль; ротація болісна. Прийнятно для простих інтеграцій із ротацією й обмеженням за IP.
2. Підпис запиту (HMAC) - секрет не передається мережею, передається підпис:
X-Timestamp: 1760000000
X-Signature: hex(HMAC-SHA256(secret, method + path + timestamp + sha256(body)))
Отримувач обчислює підпис сам і порівнює у постійному часі (hash_equals). Мітка часу з коротким вікном (кілька хвилин) захищає від повторного відтворення. Так підписують вебхуки (Stripe, GitHub) і запити AWS (SigV4).
3. Взаємний TLS (mTLS) - обидві сторони пред'являють сертифікати:
- сервер перевіряє клієнтський сертифікат, виданий вашим внутрішнім центром сертифікації;
- ідентичність сервісу - у сертифікаті, а не в секреті в коді;
- RFC 8705 описує прив'язку OAuth-токенів до сертифіката: викрадений токен без приватного ключа клієнта непридатний.
Мінус - інфраструктура: власний CA, видача й ротація сертифікатів. Service mesh (Istio, Linkerd) роблять mTLS прозорим для застосунку.
4. Короткоживучі токени від центрального сервісу ідентифікації - OAuth 2.0 Client Credentials:
POST /oauth/token
grant_type=client_credentials&client_id=billing&client_secret=...&scope=orders:read
Сервіс отримує токен на хвилини з конкретними правами (scopes), отримувач перевіряє підпис і aud. У хмарах - ідентичності робочих навантажень (IAM-ролі, workload identity) без статичних секретів узагалі.
Як обрати:
- дві-три внутрішні інтеграції - підписані запити з ротацією секретів;
- багато сервісів, потрібні права й аудит - OAuth client credentials (у Laravel - Passport);
- мережа з нульовою довірою, високі вимоги - mTLS, часто разом із токенами.
Обов'язкове в будь-якому варіанті: найменші права для кожного сервісу, ротація секретів без простою (два дійсні ключі на час переходу), журнал викликів.
Версія в URL - найпоширеніший і найпростіший для клієнтів варіант:
// bootstrap/app.php
->withRouting(
api: __DIR__.'/../routes/api.php',
apiPrefix: 'api',
then: function () {
Route::middleware('api')->prefix('api/v2')->name('v2.')
->group(base_path('routes/api_v2.php'));
},
)
routes/api.php → /api/v1/... (або /api/...)
routes/api_v2.php → /api/v2/...
Альтернативи - версія в заголовку (Accept: application/vnd.myapp.v2+json) чи окремий параметр. Вони «чистіші» з погляду REST, але гірше видимі в логах, кешах CDN і при налагодженні.
Що саме відрізняється між версіями - здебільшого формат даних, а не бізнес-логіка. Тому дублювати весь код не потрібно:
app/Http/Controllers/Api/V1/OrderController.php
app/Http/Controllers/Api/V2/OrderController.php ← тонкі контролери
app/Http/Resources/V1/OrderResource.php
app/Http/Resources/V2/OrderResource.php ← різний формат відповіді
app/Actions/CreateOrder.php ← спільна логіка
- ресурси й Form Request-и - за версіями (формат входу й виходу);
- сервіси, actions, моделі, політики - спільні;
- нова версія створюється лише для ендпойнтів, що змінилися; решта може посилатися на контролери попередньої версії.
Коли нова версія потрібна - лише для ламаючих змін: перейменування чи видалення полів, зміна типів, обов'язкові нові параметри, зміна семантики. Додавання нових полів і ендпойнтів - не привід для нової версії.
Підтримка старих версій:
- заголовки застарівання на відповідях старої версії (
Deprecation,Sunsetз датою вимкнення) і посилання на інструкцію з міграції; - моніторинг використання: скільки запитів і від яких клієнтів іде на стару версію - без цього неможливо вирішити, коли її вимикати;
- мобільні клієнти оновлюються роками - стара версія може жити довго, тож кожна нова версія - це додаткова підтримка.
Тестування: набори тестів для кожної підтримуваної версії - зміна спільної логіки не має ламати стару версію.
Альтернатива версіям - еволюція без ламання: додавати, але не змінювати; нові поля замість зміни старих; «розширювані» енуми. Багато команд обходяться однією версією роками, якщо дотримуються цих правил.
Sanctum і Passport обидва дають автентифікацію API, але розв'язують різні задачі.
Sanctum - для власних клієнтів:
- сесійна автентифікація для власного SPA;
- персональні токени для мобільних застосунків, CLI, інтеграцій, які користувач створює сам;
- простий: одна таблиця токенів, мінімум налаштувань.
Passport - повноцінний OAuth 2.0 сервер. Потрібен, коли:
1. Сторонні застосунки отримують доступ від імені користувачів - сценарій «Увійти через ваш сервіс» / «Дозволити застосунку X доступ до вашого облікового запису»:
Застосунок партнера → перенаправлення на ваш сайт → користувач погоджується
→ партнер отримує токен з обмеженими правами (scopes)
Це Authorization Code flow (з PKCE для публічних клієнтів). Користувач не передає партнеру пароль, бачить, які права надає, і може відкликати доступ.
2. Сервер-сервер інтеграції за стандартом - Client Credentials grant: машинний клієнт отримує короткоживучий токен без участі користувача.
3. Потрібен стандарт, який розуміють сторонні інструменти - бібліотеки OAuth-клієнтів, API-шлюзи, OpenID Connect-подібні сценарії.
4. Refresh-токени й короткоживучі токени доступу зі стандартною логікою оновлення.
Що дає Passport:
- реєстрація OAuth-клієнтів (
php artisan passport:client), екран згоди; - scopes (
Passport::tokensCan([...])) і перевірка через middleware; - токени доступу у форматі JWT, підписані ключами (
passport:keys); - терміни дії й refresh-токени (
Passport::tokensExpireIn(),refreshTokensExpireIn()).
Ціна Passport:
- складніша конфігурація й більше таблиць;
- ключі шифрування треба безпечно зберігати й розгортати на всіх серверах;
- OAuth - великий стандарт з багатьма способами помилитися (redirect URI, PKCE, зберігання секретів клієнтів).
Правило вибору:
| Сценарій | Інструмент |
|---|---|
| власний SPA | Sanctum (сесія) |
| власний мобільний застосунок | Sanctum (токени) |
| користувач створює токени для своїх скриптів | Sanctum |
| сторонні застосунки з доступом від імені користувачів | Passport |
| ваш сервіс як провайдер «Увійти через ...» | Passport |
| сервер-сервер за стандартом OAuth | Passport (client credentials) |
Поширена помилка - Passport «на виріст» для звичайного SPA чи мобільного застосунку: складність OAuth без жодної з його переваг.
Стандартні JSON-помилки Laravel різняться за форматом: {"message": "..."} для одних, {"message", "errors"} для валідації. Для публічного API зручніше один формат - наприклад, Problem Details (RFC 9457, application/problem+json).
Централізований рендеринг у bootstrap/app.php:
use Illuminate\Validation\ValidationException;
use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface;
->withExceptions(function (Exceptions $exceptions): void {
$exceptions->shouldRenderJsonWhen(fn (Request $r) => $r->is('api/*') || $r->expectsJson());
$exceptions->render(function (ValidationException $e, Request $request) {
if (! $request->is('api/*')) {
return null; // стандартна поведінка для веб-форм
}
return response()->json([
'type' => 'https://api.example.com/problems/validation',
'title' => 'Дані не пройшли перевірку',
'status' => 422,
'errors' => $e->errors(),
], 422, ['Content-Type' => 'application/problem+json']);
});
// ApiProblemException - власний базовий клас доменних помилок застосунку
$exceptions->render(function (ApiProblemException $e, Request $request) {
if ($request->is('api/*')) {
return response()->json([
'type' => $e->problemType(),
'title' => $e->getMessage(),
'status' => $e->status(),
], $e->status(), ['Content-Type' => 'application/problem+json']);
}
});
})
Доменні винятки з власним рендерингом - клас знає свій статус і тип:
final class OrderAlreadyShipped extends Exception
{
public function render(Request $request): JsonResponse
{
return response()->json([
'type' => 'https://api.example.com/problems/order-already-shipped',
'title' => 'Замовлення вже відправлено',
'status' => 409,
'order_id' => $this->orderId,
], 409, ['Content-Type' => 'application/problem+json']);
}
}
Що врахувати:
- винятки фреймворку теж мають іти в загальному форматі:
AuthenticationException(401),AuthorizationException/AccessDeniedHttpException(403),NotFoundHttpExceptionіModelNotFoundException(404),ThrottleRequestsException(429), будь-якийHttpExceptionInterface- через$e->getStatusCode(); 500на продакшені - без деталей винятку: загальнийtitleі ідентифікатор для підтримки (instanceчиtrace_id), за яким знайдеться запис у логах;- звітування не змінюється:
renderвпливає лише на відповідь, винятки й далі логуються й ідуть у Sentry/Flare; type- стабільний URL з описом помилки в документації; клієнти орієнтуються на нього, а не на текстtitle;- тести: для кожного типу помилки - перевірка статусу,
Content-Typeі структури (assertJsonStructure).
Зміна формату помилок - ламаюча зміна для наявних клієнтів, тож його варто обрати на старті API.
Час відповіді API складається з запитів до бази, гідрації моделей Eloquent, серіалізації ресурсів і розміру відповіді. Оптимізація - по кожному кроку, після вимірювання.
1. Запити до бази:
- N+1 -
with()у контролері,whenLoadedу ресурсах,Model::preventLazyLoading()у розробці; - лише потрібні колонки:
select(['id', 'title', 'author_id', 'created_at'])- менше даних з бази і менше пам'яті на гідрацію (не забути зовнішні ключі дляwith); - агрегати в базі:
withCount,withSumзамість завантаження зв'язків для підрахунку; - індекси під фільтри й сортування ендпойнта.
2. Обсяг відповіді:
- пагінація обов'язкова для колекцій, з верхньою межею
per_page; cursorPaginate/simplePaginate- безCOUNT(*)на великих таблицях;- розріджені поля й включення на вимогу (
?fields[posts]=id,title,?include=author) - клієнт отримує лише потрібне. ВбудованийJsonApiResourceLaravel 13 підтримує обидва механізми за специфікацією JSON:API; - стиснення (gzip/brotli) на рівні вебсервера.
3. Гідрація й серіалізація:
- Eloquent-моделі дорогі для тисяч рядків. Для великих вивантажень -
toBase()/query builder без моделей абоlazy()/cursor()з потоковою відповіддю (response()->streamJson()), а не масив у пам'яті; - важкі обчислення в
toArray(форматування, URL, звернення до сервісів) множаться на кількість елементів - винести в запит чи кеш.
4. Кешування:
- HTTP-кешування:
ETag/Last-Modifiedі304 Not Modified- клієнт не завантажує незмінене;Cache-Controlдля публічних даних - кеш на CDN; - кеш застосунку для дорогих агрегацій (
Cache::flexible()- stale-while-revalidate: віддає застаріле значення й оновлює у фоні); - кеш на рівні запитів до бази - точково, з продуманою інвалідацією.
5. Інфраструктура:
- Octane (FrankenPHP, Swoole, RoadRunner) - застосунок у пам'яті між запитами, без завантаження фреймворку на кожен запит;
- черги для всього, що не потрібне для відповіді (листи, вебхуки, аналітика);
- асинхронні операції -
202 Acceptedз посиланням на статус для довгих задач замість очікування в запиті.
Як вимірювати: Telescope/Debugbar (кількість запитів і час), Pulse (повільні ендпойнти й запити в продакшені), профайлер для гарячих точок у серіалізації. Оптимізація без вимірювань часто прискорює не те.
Проблема втраченого оновлення. Два менеджери відкрили одне замовлення. Перший змінив адресу доставки й зберіг. Другий, дивлячись на стару версію, змінив коментар і зберіг - і перезаписав адресу першого старим значенням. Ніхто не отримав помилки.
Оптимістичне блокування через умовні запити:
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 не підходять - порівняння має бути сильним.
Питання рівня Senior з реальних технічних співбесід - 30 питань у 7 темах, розібраних із відповідями. Нижче - теми цього рівня та сусідні рівні, якщо хочете звузити або розширити підготовку.
Готуєтесь до співбесіди не просто так: зараз на сайті 47 відкритих вакансій рівня Senior. Переглянути вакансії