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 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-му запиті.
Для власного 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()додає в групуapimiddleware, що вмикає сесію й 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 }),
});
/sanctum/csrf-cookieставить cookieXSRF-TOKEN;- вхід - звичайний маршрут логіну (Fortify, власний контролер з
Auth::attempt); - далі всі запити з
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_atSanctum оновлює автоматично), кнопка відкликання; - тести:
Sanctum::actingAs($user, ['orders:read'])- і перевірка, що запис заборонено.
Докладніше в документації: Laravel Sanctum: можливості токенів