Питання на співбесіді: API у Laravel
Питання з реальних співбесід з відповідями: Laravel і PHP, бази даних, JavaScript і фронтенд, Git, Docker, API, безпека й архітектура. Тими самими темами, що й тести.
14 питань
У свіжому застосунку Laravel файлу routes/api.php немає - його додає команда:
php artisan install:api
Вона встановлює Laravel Sanctum (автентифікація токенами), створює routes/api.php і підключає його в bootstrap/app.php:
->withRouting(
web: __DIR__.'/../routes/web.php',
api: __DIR__.'/../routes/api.php',
// apiPrefix: 'api/v1',
)
Чим маршрути API відрізняються:
web.php |
api.php |
|
|---|---|---|
| префікс URL | немає | /api (змінюється через apiPrefix) |
| група middleware | web |
api |
| сесія й cookie | так | ні (stateless) |
| CSRF-захист | так | ні |
| автентифікація | сесія | токени (auth:sanctum) |
// routes/api.php
Route::middleware('auth:sanctum')->group(function () {
Route::get('/user', fn (Request $request) => $request->user());
Route::apiResource('posts', PostController::class);
});
apiResource реєструє маршрути ресурсу без create і edit (вони потрібні лише для HTML-форм): index, store, show, update, destroy. Контролер - php artisan make:controller PostController --api --model=Post.
Що варто знати:
- група
apiза замовчуванням не обмежує частоту запитів. Обмеження вмикається явно: визначити лімітерRateLimiter::for('api', ...)і підключити$middleware->throttleApi()вbootstrap/app.php(абоthrottle:apiна маршрутах); - відповіді-помилки для запитів з
Accept: application/jsonLaravel повертає в JSON. Клієнтам API варто завжди надсилати цей заголовок - інакше помилка валідації може стати редиректом; - власний SPA на тому ж домені може ходити в
api.phpз сесійною автентифікацією Sanctum ($middleware->statefulApi()), без токенів; - маршрути з
web.phpтеж можуть віддавати JSON - для внутрішніх запитів Livewire/Inertia-застосунку окремий API часто не потрібен.
Перевірка: php artisan route:list --path=api показує всі маршрути API з middleware.
Повернути модель з контролера можна - Laravel серіалізує її в JSON автоматично:
return $user; // усі атрибути моделі (крім $hidden)
Але так формат відповіді API = структура таблиці. Додали колонку - вона з'явилася в API. Перейменували - зламали клієнтів. Внутрішнє поле (прапорець, службова дата) - уже публічне.
API Resource - окремий шар, що явно описує, як модель виглядає назовні:
php artisan make:resource UserResource
class UserResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'name' => $this->name,
'avatar_url' => $this->avatarUrl(),
'registered_at' => $this->created_at,
'posts_count' => $this->whenCounted('posts'),
'email' => $this->when($request->user()?->is($this->resource), $this->email),
];
}
}
return new UserResource($user);
return UserResource::collection($users);
return $user->toResource(); // те саме, коротше
Що дає ресурс:
- контракт API відокремлено від бази: перейменування колонки змінює лише ресурс, а не відповідь;
- явний перелік полів: нове поле в таблиці не з'явиться в API випадково;
- обчислювані поля (URL, форматування) і умовні поля за правами;
- вкладені ресурси й зв'язки - лише якщо вони завантажені (
whenLoaded); - обгортка
data, пагінація зlinksіmeta- автоматично для колекцій.
Обгортка data: відповідь має вигляд { "data": { ... } }. Вимкнути - JsonResource::withoutWrapping() у сервіс-провайдері, але для колекцій з пагінацією обгортка корисна (там же meta).
JSON:API. Якщо потрібен стандартний формат специфікації JSON:API (type, id, attributes, relationships, included), у Laravel 13 є вбудований JsonApiResource - php artisan make:resource PostResource --json-api.
Пастка: ресурс не захищає від N+1. Якщо в toArray звертатися до незавантажених зв'язків ($this->author->name), кожен елемент колекції - окремий запит. Звідси whenLoaded і with() у контролері.
Laravel вирішує, як відповісти на виняток - HTML-сторінкою чи JSON, - за заголовком Accept запиту ($request->expectsJson()).
Помилка валідації для запиту з Accept: application/json:
HTTP/1.1 422 Unprocessable Content
{
"message": "The email field is required. (and 1 more error)",
"errors": {
"email": ["The email field is required."],
"password": ["The password field must be at least 8 characters."]
}
}
Без Accept: application/json та сама помилка валідації - це редирект назад (302) з помилками в сесії, як для HTML-форм. Клієнт API отримає HTML сторінки замість зрозумілої помилки. Найчастіша причина «API повертає 302 замість 422».
Інші типові відповіді:
| Ситуація | Статус |
|---|---|
немає чи недійсний токен (AuthenticationException) |
401 {"message": "Unauthenticated."} |
authorize() / політика відмовила |
403 |
модель не знайдено (findOrFail, прив'язка маршруту) |
404 |
перевищено ліміт (throttle) |
429 з Retry-After |
| виняток у коді | 500 |
APP_DEBUG=true додає до відповіді 500 повідомлення винятку, файл, рядок і стек. На продакшені - обов'язково false, інакше API розкриває внутрішню будову коду.
Примусово JSON для всіх маршрутів API - незалежно від заголовка клієнта:
// bootstrap/app.php
->withExceptions(function (Exceptions $exceptions): void {
$exceptions->shouldRenderJsonWhen(
fn (Request $request, Throwable $e) => $request->is('api/*') || $request->expectsJson(),
);
})
Власний формат для конкретного винятку:
$exceptions->render(function (OrderAlreadyShippedException $e, Request $request) {
return response()->json(['message' => 'Замовлення вже відправлено'], 409);
});
Для клієнтів API варто задокументувати: завжди надсилати Accept: application/json, а формат помилки - єдиний для всіх ендпойнтів.
Докладніше в документації: Laravel: рендеринг винятків як JSON
Laravel Sanctum - легка автентифікація для API: персональні токени доступу (мобільні застосунки, інтеграції) і сесійна автентифікація для SPA.
Модель користувача:
use Laravel\Sanctum\HasApiTokens;
class User extends Authenticatable
{
use HasApiTokens;
}
Видача токена (наприклад, для входу з мобільного застосунку):
Route::post('/tokens', function (Request $request) {
$request->validate([
'email' => ['required', 'email'],
'password' => ['required'],
'device_name' => ['required', 'string', 'max:255'],
]);
$user = User::where('email', $request->email)->first();
if (! $user || ! Hash::check($request->password, $user->password)) {
throw ValidationException::withMessages(['email' => ['Невірні облікові дані.']]);
}
return ['token' => $user->createToken($request->device_name)->plainTextToken];
});
Токен має вигляд 5|xYz...: id запису й випадкова частина.
Використання - заголовок Authorization:
GET /api/user
Authorization: Bearer 5|xYz...
Accept: application/json
Route::get('/user', fn (Request $request) => $request->user())->middleware('auth:sanctum');
Як Sanctum зберігає токени: у таблиці personal_access_tokens лежить лише SHA-256-хеш випадкової частини. Відкритий токен показується один раз при створенні - потім його неможливо відновити, лише створити новий. Витік бази не дає готових токенів.
Відкликання:
$request->user()->currentAccessToken()->delete(); // вихід з цього пристрою
$user->tokens()->delete(); // вихід з усіх пристроїв
Термін дії: за замовчуванням токени не мають терміну дії. Його задають глобально (expiration у config/sanctum.php, хвилини) або для конкретного токена третім аргументом createToken. Прострочені записи прибирає sanctum:prune-expired у планувальнику.
Що варто зробити в продакшені:
- обмежити частоту запитів до ендпойнта видачі токенів (захист від перебору паролів);
- задати термін дії токенів;
device_nameзрозумілий користувачу - щоб він міг побачити список пристроїв і відкликати зайвий;- префікс токенів (
token_prefixу конфігурації) - сканери секретів (наприклад, GitHub) зможуть розпізнати токен, що потрапив у публічний репозиторій.
Form Request - окремий клас для валідації й авторизації запиту. Контролер отримує вже перевірені дані.
php artisan make:request StoreOrderRequest
class StoreOrderRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user()->can('create', Order::class);
}
public function rules(): array
{
return [
'items' => ['required', 'array', 'min:1', 'max:50'],
'items.*.product_id' => ['required', 'integer', Rule::exists('products', 'id')->where('active', true)],
'items.*.qty' => ['required', 'integer', 'between:1,100'],
'comment' => ['nullable', 'string', 'max:1000'],
];
}
}
public function store(StoreOrderRequest $request): JsonResponse
{
$order = $this->orders->create($request->user(), $request->validated());
return (new OrderResource($order))->response()->setStatusCode(201);
}
Що відбувається автоматично:
- Laravel створює запит і викликає
authorize()до контролера.false- відповідь403; rules()- валідація; помилки -422з полемerrors(для запитів зAccept: application/json);- контролер виконується лише якщо все пройшло.
Чому це краще за $request->validate() у контролері:
- контролер коротший і читається як бізнес-логіка;
- правила й авторизацію легко перевикористати (створення й оновлення часто ділять більшість правил);
$request->validated()- лише перевірені поля. Передавати їх уcreate()безпечно: зайве поле з тіла запиту (is_admin) туди не потрапить.
Корисні можливості:
prepareForValidation()- нормалізувати вхідні дані до перевірки (обрізати пробіли, привести телефон до одного формату);after()- перевірки, що охоплюють кілька полів або потребують бази;messages()іattributes()- власні тексти помилок і назви полів;$stopOnFirstFailure- зупинити валідацію на першій помилці.
Пастки API:
- межі масивів (
max:50) і рядків обов'язкові: клієнт може надіслати мегабайти даних; existsз умовами (where('active', true)) - інакше можна замовити неактивний чи чужий товар;sometimesдляPATCH: поле перевіряється, лише якщо прийшло, - часткове оновлення не вимагає всіх полів;- авторизація конкретного об'єкта (
$this->route('order')) вauthorize()- захист від доступу до чужих записів.
Якщо передати в колекцію ресурсів пагінатор, 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: можливості токенів
Версія в 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 (повільні ендпойнти й запити в продакшені), профайлер для гарячих точок у серіалізації. Оптимізація без вимірювань часто прискорює не те.