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

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

Тестування: набори тестів для кожної підтримуваної версії - зміна спільної логіки не має ламати стару версію.

Альтернатива версіям - еволюція без ламання: додавати, але не змінювати; нові поля замість зміни старих; «розширювані» енуми. Багато команд обходяться однією версією роками, якщо дотримуються цих правил.

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

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 без жодної з його переваг.

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

Стандартні 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.

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

Час відповіді 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) - клієнт отримує лише потрібне. Вбудований JsonApiResource Laravel 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 (повільні ендпойнти й запити в продакшені), профайлер для гарячих точок у серіалізації. Оптимізація без вимірювань часто прискорює не те.

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