Senior: питання на співбесіді з теми «API у Laravel»
Питання з реальних співбесід з відповідями: Laravel і PHP, бази даних, JavaScript і фронтенд, Git, Docker, API, безпека й архітектура. Тими самими темами, що й тести.
4 питання
Версія в 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 (повільні ендпойнти й запити в продакшені), профайлер для гарячих точок у серіалізації. Оптимізація без вимірювань часто прискорює не те.