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

Middle: питання на співбесіді з теми «API у Laravel»

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

5 питань

Якщо передати в колекцію ресурсів пагінатор, Laravel додає до відповіді посилання й метадані пагінації:

public function index(Request $request)
{
    $posts = Post::query()
        ->with('author')
        ->latest()
        ->paginate(perPage: min((int) $request->integer('per_page', 20), 100));

    return PostResource::collection($posts);
}
{
  "data": [ { "id": 41, "title": "..." } ],
  "links": {
    "first": "https://example.com/api/posts?page=1",
    "last": "https://example.com/api/posts?page=12",
    "prev": null,
    "next": "https://example.com/api/posts?page=2"
  },
  "meta": {
    "current_page": 1,
    "from": 1,
    "last_page": 12,
    "path": "https://example.com/api/posts",
    "per_page": 20,
    "to": 20,
    "total": 235
  }
}

Три види пагінаторів:

Метод Що дає Ціна
paginate() номери сторінок, total, last_page додатковий COUNT(*)
simplePaginate() лише next/prev без підрахунку
cursorPaginate() курсор замість номера сторінки без OFFSET, стабільна на змінних даних

Як обрати:

  • адмінки й таблиці з переходом на довільну сторінку - paginate();
  • великі таблиці - COUNT(*) по мільйонах рядків дорогий, тож simplePaginate() або cursorPaginate();
  • стрічки й нескінченний скрол, мобільні застосунки - cursorPaginate(): без пропусків і дублікатів, коли між запитами додаються нові записи (зі OFFSET новий запис зсуває сторінки, і клієнт отримує один елемент двічі).

Що варто врахувати:

  • обмежити per_page зверху - інакше ?per_page=1000000 вивантажить усю таблицю;
  • стабільне сортування: для курсорної пагінації потрібне унікальне сортування (orderBy('created_at')->orderBy('id')), інакше записи з однаковою датою губляться;
  • параметри запиту зберігаються в посиланнях links через ->withQueryString() (фільтри, сортування);
  • N+1: with() до пагінації, а в ресурсі - whenLoaded;
  • власні метадані - метод paginationInformation() у класі колекції чи additional() для додаткових полів відповіді.

meta.total - частина контракту: перейшовши з paginate на cursorPaginate, ви прибираєте total, last_page і номери сторінок - це зміна, що ламає клієнтів.

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

Проблема: ресурс звертається до зв'язку, який не завантажено, - і на кожен елемент колекції виконується окремий запит.

// у ресурсі
'author' => new UserResource($this->author),   // N+1 для колекції з 50 постів - 51 запит

whenLoaded включає зв'язок у відповідь лише якщо його вже завантажено - і сам нічого не завантажує:

class PostResource extends JsonResource
{
    public function toArray(Request $request): array
    {
        return [
            'id' => $this->id,
            'title' => $this->title,
            'author' => new UserResource($this->whenLoaded('author')),
            'tags' => TagResource::collection($this->whenLoaded('tags')),
            'comments_count' => $this->whenCounted('comments'),
            'average_rating' => $this->whenAggregated('reviews', 'rating', 'avg'),
        ];
    }
}

Якщо зв'язок не завантажено, ключ зникає з відповіді (а не стає null).

Контролер вирішує, що завантажити:

$posts = Post::query()
    ->with(['author', 'tags'])
    ->withCount('comments')
    ->withAvg('reviews', 'rating')
    ->paginate();

return PostResource::collection($posts);

Той самий ресурс у списку (мінімум зв'язків) і на сторінці деталей (більше зв'язків) - різна кількість полів без двох окремих класів.

Включення на вимогу клієнта (?include=author,tags) - дозволений перелік, а не довільні зв'язки:

$allowed = ['author', 'tags', 'comments'];
$includes = array_intersect(explode(',', $request->string('include')), $allowed);

$posts = Post::with($includes)->paginate();

Пакет spatie/laravel-query-builder робить це разом із фільтрами й сортуванням. Вбудований JsonApiResource у Laravel 13 підтримує include за специфікацією JSON:API.

Інші помічники ресурсів:

  • $this->when($condition, $value) - поле за умовою (права, контекст);
  • $this->mergeWhen($condition, [...]) - кілька полів разом;
  • $this->whenPivotLoaded('role_user', fn () => ...) - дані проміжної таблиці.

Як ловити N+1 в API:

  • Model::preventLazyLoading(! app()->isProduction()) - виняток при ледачому завантаженні в розробці й тестах;
  • Telescope, Debugbar - кількість запитів на ендпойнт;
  • тест, що перевіряє кількість запитів для колекції (DB::enableQueryLog() / expectsDatabaseQueryCount).

Пастка: whenLoaded приховує «відсутні» дані - клієнт може не помітити, що зв'язок перестав приходити, бо контролер забув with(). Тому у важливих ендпойнтах варто мати тести структури відповіді.

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

Важливо: у Laravel 11-13 група middleware api за замовчуванням не обмежує частоту запитів. Обмеження треба ввімкнути явно.

1. Визначити лімітер (в AppServiceProvider::boot()):

use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Support\Facades\RateLimiter;

RateLimiter::for('api', function (Request $request) {
    return Limit::perMinute(60)->by($request->user()?->id ?: $request->ip());
});

2. Підключити - для всієї групи api:

// bootstrap/app.php
->withMiddleware(function (Middleware $middleware): void {
    $middleware->throttleApi();            // throttle:api для групи api
    // $middleware->throttleApi(redis: true);
})

або точково на маршрутах: ->middleware('throttle:api').

Різні ліміти для різних клієнтів і дій:

RateLimiter::for('api', function (Request $request) {
    $user = $request->user();

    return match (true) {
        $user === null => Limit::perMinute(30)->by($request->ip()),
        $user->isPremium() => Limit::perMinute(600)->by($user->id),
        default => Limit::perMinute(120)->by($user->id),
    };
});

RateLimiter::for('login', fn (Request $request) => [
    Limit::perMinute(500),                                    // загальний для ендпойнта
    Limit::perMinute(5)->by($request->input('email').'|'.$request->ip()),
]);

RateLimiter::for('exports', fn (Request $request) => Limit::perDay(10)->by('exports:'.$request->user()->id));

Що отримує клієнт: при перевищенні - 429 Too Many Requests із заголовками Retry-After і X-RateLimit-Limit/X-RateLimit-Remaining. Власна відповідь - Limit::perMinute(60)->response(...).

Що враховувати:

  • ключ (by) визначає, кого рахувати: користувача, IP, токен, організацію. Для кількох лімітів з однаковим ключем - префікси ('minute:'.$id, 'day:'.$id), інакше лічильники змішаються;
  • IP за проксі - Laravel має довіряти проксі (trustProxies), інакше всі клієнти матимуть IP балансувальника й поділять один ліміт;
  • сховище лічильників - кеш застосунку. На кількох серверах потрібен спільний кеш (Redis), throttleApi(redis: true) використовує ефективніший Redis-middleware;
  • дорогі ендпойнти (пошук, експорт, генерація звітів) - окремі суворіші лімітери, а не один на все API;
  • ліміти на рівні CDN/WAF (Cloudflare) - перший рубіж від масових атак; лімітери застосунку - для справедливого розподілу між клієнтами.

Тестування: RateLimiter::clear($key) між тестами або перевірка 429 на N+1-му запиті.

Докладніше в документації: Laravel: обмеження частоти

Для власного SPA (Vue, React) Sanctum пропонує не токени, а звичайну сесію Laravel з cookie. Токен у JavaScript не потрапляє взагалі - XSS не зможе його вкрасти, а вихід знищує сесію на сервері.

Умова: SPA і API - на одному домені верхнього рівня (можна різні піддомени: app.example.com і api.example.com).

Налаштування на бекенді:

// bootstrap/app.php
->withMiddleware(function (Middleware $middleware): void {
    $middleware->statefulApi();   // сесія для запитів зі «своїх» доменів
})
SANCTUM_STATEFUL_DOMAINS=app.example.com
SESSION_DOMAIN=.example.com
  • statefulApi() додає в групу api middleware, що вмикає сесію й CSRF для запитів із доменів зі списку stateful;
  • SESSION_DOMAIN з крапкою - cookie сесії доступна піддоменам;
  • CORS (config/cors.php): дозволити домен SPA і supports_credentials => true, бо браузер має надсилати cookie в міжсайтовий запит.

Вхід з SPA:

await fetch('https://api.example.com/sanctum/csrf-cookie', { credentials: 'include' });

await fetch('https://api.example.com/login', {
  method: 'POST',
  credentials: 'include',
  headers: {
    'Content-Type': 'application/json',
    Accept: 'application/json',
    'X-XSRF-TOKEN': decodeURIComponent(readCookie('XSRF-TOKEN')),
  },
  body: JSON.stringify({ email, password }),
});
  1. /sanctum/csrf-cookie ставить cookie XSRF-TOKEN;
  2. вхід - звичайний маршрут логіну (Fortify, власний контролер з Auth::attempt);
  3. далі всі запити з credentials: 'include' і тим самим заголовком CSRF; маршрути захищені auth:sanctum.

Axios передає X-XSRF-TOKEN автоматично.

Як Sanctum розрізняє SPA і сторонніх клієнтів: за заголовками Origin/Referer. Якщо запит з домену зі списку stateful - сесія й CSRF; інакше - автентифікація токеном. Той самий auth:sanctum працює для обох.

Типові проблеми:

  • 419 CSRF token mismatch - не викликано /sanctum/csrf-cookie, не передано заголовок або домен SPA не в stateful (порт теж має збігатися: localhost:5173);
  • 401 після успішного входу - cookie не надсилається: немає credentials: 'include', неправильний SESSION_DOMAIN чи supports_credentials;
  • різні домени (app.com і api.net) - cookie-автентифікація не працює; потрібні токени чи BFF.

Коли не підходить: мобільні застосунки й сторонні інтеграції - для них токени.

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

Abilities у Sanctum - аналог OAuth-scopes: токен може робити лише те, що йому дозволено при створенні.

$token = $user->createToken('CI deploy', ['servers:read', 'servers:deploy'])->plainTextToken;
$readOnly = $user->createToken('Звіти в Google Sheets', ['reports:read'])->plainTextToken;

Без другого аргументу токен отримує ['*'] - усі можливості.

Перевірка в коді:

if ($request->user()->tokenCant('servers:deploy')) {
    abort(403);
}

Перевірка middleware на маршрутах (аліаси реєструються в bootstrap/app.php):

->withMiddleware(function (Middleware $middleware): void {
    $middleware->alias([
        'abilities' => \Laravel\Sanctum\Http\Middleware\CheckAbilities::class,
        'ability' => \Laravel\Sanctum\Http\Middleware\CheckForAnyAbility::class,
    ]);
})
Route::post('/servers/{server}/deploy', DeployController::class)
    ->middleware(['auth:sanctum', 'abilities:servers:deploy']);   // потрібні всі перелічені

Route::get('/reports', ReportController::class)
    ->middleware(['auth:sanctum', 'ability:reports:read,admin']);  // достатньо будь-якої

Головне правило: abilities обмежують токен, але не замінюють авторизацію користувача. Токен з servers:deploy дає право деплоїти лише ті сервери, до яких має доступ сам користувач. Перевірка - обидві:

public function deploy(Request $request, Server $server)
{
    abort_unless($request->user()->tokenCan('servers:deploy'), 403);
    $this->authorize('deploy', $server);   // політика: чи це сервер користувача
}

Особливість для SPA: при сесійній автентифікації (Sanctum SPA) tokenCan() завжди повертає true - запит від власного фронтенду, обмежень токена немає. Тому логіка «що можна користувачу» має бути в політиках, а abilities - лише додаткове обмеження для токенів.

Практичні поради:

  • принцип найменших прав: інтеграції видавати токени з мінімальним набором можливостей;
  • читабельні назви - ресурс:дія (orders:read, orders:write);
  • інтерфейс керування токенами для користувача: назва, можливості, дата останнього використання (last_used_at Sanctum оновлює автоматично), кнопка відкликання;
  • тести: Sanctum::actingAs($user, ['orders:read']) - і перевірка, що запис заборонено.

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