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

API: питання на співбесіді рівня Middle

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

35 питань

Клієнту API потрібно не лише знати, що пішло не так, а й обробити це програмно. Для цього помилки мають бути в одному передбачуваному форматі на весь API.

Problem Details (RFC 9457, замінив RFC 7807) - стандартний формат з типом application/problem+json:

{
  "type": "https://api.example.com/problems/insufficient-funds",
  "title": "Недостатньо коштів",
  "status": 422,
  "detail": "На рахунку 30 грн, а списання - 50 грн.",
  "instance": "/payments/8f3a",
  "balance": 3000
}
  • type - URI типу помилки, стабільний ідентифікатор для програмної обробки (бажано - з документацією за цією адресою).
  • title - короткий опис типу, однаковий для всіх його випадків.
  • status - HTTP-код (дублює заголовок для зручності).
  • detail - пояснення саме цього випадку.
  • instance - ідентифікатор конкретного випадку.
  • Можна додавати власні поля - як balance вище чи список помилок валідації за полями.

Принципи, незалежно від формату:

  • Правильний HTTP-код - формат тіла його не замінює.
  • Машиночитний код помилки (type чи code), а не лише текст: текст змінюють і перекладають, а клієнти на нього не мають покладатися.
  • Помилки валідації за полями, щоб клієнт підсвітив поля форми.
  • Жодних стек-трейсів, SQL і шляхів до файлів у відповіді на проді.
  • ID запиту в тілі чи заголовку - щоб знайти помилку в логах за зверненням клієнта.

Докладніше в документації: RFC 9457: Problem Details for HTTP APIs

Основні правила:

  • Іменники, а не дієслова: дія задається HTTP-методом. GET /orders, POST /orders, DELETE /orders/42 - а не /getOrders, /createOrder.
  • Множина для колекцій: /orders і /orders/42 - узгоджено для всього API.
  • Вкладеність для відношень, але неглибока: /users/42/orders - так; /users/42/orders/7/items/3/reviews - ні. Ресурс з власним ID краще адресувати напряму: /order-items/3.
  • Нижній регістр і дефіси: /payment-methods, а не /paymentMethods чи /payment_methods.
  • Фільтрація, сортування, пагінація - у query-параметрах: /orders?status=paid&sort=-created_at&page[size]=20.
  • Стабільні ідентифікатори в URL: ID чи UUID, а не назви, які можуть змінитися.

Дії, що не вкладаються в CRUD:

  • як під-ресурс стану: POST /orders/42/cancellation;
  • як дія-під-ресурс: POST /orders/42/cancel - прагматично й зрозуміло, і так роблять великі API (Stripe, GitHub);
  • головне - узгодженість у межах API.

Відповіді:

  • Узгоджене іменування полів (snake_case чи camelCase - одне на весь API).
  • Дати - в ISO 8601 з часовим поясом.
  • Гроші - цілими числами в мінімальних одиницях або рядками, з валютою.
  • Посилання на пов'язані ресурси чи їхні ID, а не дублювання цілих об'єктів без потреби.

Найважливіше - передбачуваність: розробник, побачивши два ендпоінти, має вгадати третій. Опис в OpenAPI допомагає тримати цю узгодженість і генерувати клієнти.

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

Вкладений ресурс відображає відношення «належить до» в URL:

GET  /posts/42/comments        # коментарі конкретного поста
POST /posts/42/comments        # додати коментар до поста

Коли вкладеність доречна:

  • дочірній ресурс не має сенсу без батька (коментар без поста, позиція без замовлення);
  • створення - URL одразу задає батька, і його не треба передавати в тілі;
  • колекція в контексті: «замовлення цього клієнта» - природний запит.

Де зупинитися:

1. Не глибше одного рівня. /users/7/orders/42/items/3/discounts/1 - важко читати, і всі проміжні ідентифікатори доводиться знати й перевіряти. Якщо в дочірнього ресурсу є власний унікальний id, на ньому можна працювати напряму:

GET    /posts/42/comments      # колекція - вкладена
GET    /comments/15            # окремий коментар - плаский
PATCH  /comments/15
DELETE /comments/15

Це неглибока вкладеність (shallow nesting): вкладені лише ті маршрути, де батько потрібен (колекція й створення). У Laravel - Route::resource('posts.comments', CommentController::class)->shallow().

2. Не для фільтрації за довільними полями. /users/7/orders - добре, але /status/paid/orders - ні: це фільтр, а не ієрархія, - GET /orders?status=paid.

3. Не для зв'язків «багато-до-багатьох» без явної власності: /tags/5/posts і /posts/42/tags - обидва варіанти доречні як точки входу, але сам зв'язок - окремий ресурс.

Безпека - головна пастка вкладених URL:

GET /posts/42/comments/15

Перевірити треба не лише, що користувач має доступ до поста 42, а й що коментар 15 належить посту 42. Інакше зловмисник підставить пост, до якого має доступ, і чужий коментар. У Laravel для цього - scopeBindings():

Route::get('/posts/{post}/comments/{comment}', ...)->scopeBindings();

Тоді коментар шукається через $post->comments(), і чужий дасть 404.

Консистентність: якщо вже обрано схему (вкладені колекції + плаский доступ до елементів), - дотримуватися її для всіх ресурсів API.

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

Реальні API мають операції, які погано виражаються через «створити, прочитати, оновити, видалити»: скасувати замовлення, опублікувати статтю, надіслати рахунок, перерахувати знижку.

Варіант 1 - зміна стану через PATCH:

PATCH /orders/42
{"status": "cancelled"}

Підходить, коли дія справді лише змінює поле. Проблеми починаються, коли:

  • переходу потрібні додаткові дані (причина скасування, сума повернення);
  • переход має побічні ефекти (повернення коштів, листи, звільнення товару на складі);
  • дозволені не всі переходи (не можна скасувати відправлене замовлення).

Тоді PATCH з полем status ховає важливу бізнес-операцію за «оновленням поля», і валідація переходів розмазується.

Варіант 2 - дія як підресурс (найпоширеніший):

POST /orders/42/cancel        {"reason": "Помилка в адресі"}
POST /articles/7/publish
POST /invoices/15/send

Дієслово в URL - свідомий виняток із «лише іменників»: операція явна, має власні параметри, права й журнал аудиту.

Варіант 3 - дія як ресурс-іменник:

POST /orders/42/cancellations     # створити «скасування»
POST /refunds                     {"order_id": 42, "amount": 1000}

Корисно, коли у дії є власний життєвий цикл (повернення коштів може бути в обробці, відхиленим, завершеним) і її треба переглядати потім.

Google API (AIP-136) використовує синтаксис з двокрапкою: POST /orders/42:cancel. Він чітко відділяє дію від ієрархії ресурсів, але в Laravel-проєктах частіше бачать варіант 2.

Правила для власних дій:

  • метод POST - дія неідемпотентна чи має побічні ефекти. Для ідемпотентних (повторне «опублікувати» нічого не змінює) варто це задокументувати;
  • відповідь - оновлений ресурс (200 з тілом) або 202 Accepted, якщо дія асинхронна;
  • недопустимий перехід - 409 Conflict (замовлення вже відправлено) з поясненням у тілі;
  • права - окремо для кожної дії (can:cancel,order), а не загальне «можна оновлювати».

Головне - послідовність: один стиль для всіх дій в API.

Докладніше в документації: Google AIP-136: власні методи

Варіант 1 - multipart/form-data через сервер API:

POST /api/documents
Content-Type: multipart/form-data; boundary=...

title=Договір
file=<бінарні дані>
$request->validate(['file' => ['required', 'file', 'mimes:pdf', 'max:10240']]);
$path = $request->file('file')->store('documents', 's3');

Просто, валідація й збереження в одному запиті. Але весь файл проходить через сервер застосунку: займає процес PHP на весь час завантаження, обмежений upload_max_filesize/post_max_size і тайм-аутами, а для великих файлів - ще й проксі (client_max_body_size у Nginx).

Варіант 2 - підписаний URL і пряме завантаження в сховище (S3, R2):

  1. клієнт просить дозвіл: POST /api/uploads {"filename": "contract.pdf", "size": 52428800};
  2. сервер перевіряє права й параметри і повертає тимчасовий підписаний URL:
['url' => $url, 'headers' => $headers] = Storage::disk('s3')
    ->temporaryUploadUrl("uploads/{$id}.pdf", now()->plus(minutes: 5));
  1. клієнт завантажує файл напряму в сховище (PUT $url);
  2. клієнт повідомляє API: POST /api/documents {"upload_id": "..."} - сервер перевіряє, що файл справді з'явився, його розмір і тип, і створює запис.

Переваги прямого завантаження: сервер застосунку не тримає з'єднання, немає лімітів PHP, сховище масштабується саме, можна завантажувати частинами (multipart upload S3) з продовженням після збою.

Що обов'язково для безпеки:

  • короткий термін дії URL і фіксований шлях - клієнт не обирає, куди писати;
  • перевірка після завантаження: розмір і тип - не довіряти тому, що клієнт заявив у кроці 1. Підозрілі файли - в карантин чи на антивірусну перевірку;
  • очищення завантажених, але не підтверджених файлів (правило життєвого циклу бакета);
  • приватний бакет і видача файлів теж через підписані URL.

Варіант 3 - base64 у JSON: зручно для дуже малих файлів (аватар-мініатюра), але +33% до розміру й весь файл у пам'яті - для решти погано.

Як обрати: невеликі файли (до кількох мегабайтів) - multipart; великі, багато одночасних завантажень, мобільні клієнти з нестабільною мережею - пряме завантаження.

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

OAuth 2.0 дозволяє застосунку отримати доступ до даних користувача в іншому сервісі (Google, GitHub) без його пароля: користувач підтверджує доступ на сторінці сервісу, а застосунок отримує токен з обмеженими правами.

Authorization Code flow:

  1. Застосунок перенаправляє користувача на сервер авторизації з client_id, redirect_uri, scope і випадковим state.
  2. Користувач входить і погоджується надати доступ.
  3. Сервер повертає користувача на redirect_uri з одноразовим кодом і тим самим state.
  4. Застосунок перевіряє state (захист від CSRF) і обмінює код на токени прямим запитом сервер-сервер.
  5. Отримує access_token (короткоживучий) і часто refresh_token.

PKCE (Proof Key for Code Exchange) захищає від перехоплення коду. Перед кроком 1 застосунок генерує випадковий code_verifier і передає його хеш - code_challenge. На кроці 4 надсилає сам code_verifier. Сервер перевіряє, що хеш збігається, - тож перехоплений код без verifier'а марний.

code_verifier  = випадковий рядок 43-128 символів
code_challenge = BASE64URL(SHA256(code_verifier)), method = S256

Чому PKCE тепер обов'язковий скрізь: спершу його придумали для мобільних і SPA-застосунків, які не можуть зберігати client_secret. Сучасні рекомендації (OAuth 2.0 Security BCP, OAuth 2.1) вимагають PKCE і для серверних клієнтів.

Чого не використовувати: Implicit flow (токен одразу в URL) і Resource Owner Password flow (застосунок бере пароль користувача) - обидва вважаються застарілими й небезпечними.

У Laravel: вхід через сторонні сервіси - Socialite (він уже передає state і підтримує PKCE), власний OAuth-сервер - Passport.

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

Rate limiting обмежує, скільки запитів клієнт може зробити за проміжок часу. Захищає від перевантаження, перебору паролів і токенів, масового викачування даних і від одного «галасливого» клієнта, що забирає ресурси в інших.

Чим рахувати:

  • за користувачем чи токеном - для автентифікованих запитів, найточніше;
  • за IP - для анонімних (вхід, реєстрація, скидання пароля), з поправкою на NAT і проксі;
  • за ендпоінтом: вхід - 5 спроб на хвилину, пошук - 60, звичайні запити - більше.

Алгоритми: фіксоване вікно (просто, але дозволяє «сплеск» на межі вікон), ковзне вікно, token bucket (дозволяє короткі сплески до місткості «відра» за стабільної середньої швидкості).

Відповідь при перевищенні - 429 Too Many Requests з підказками клієнту:

HTTP/1.1 429 Too Many Requests
Retry-After: 30
X-RateLimit-Limit: 60
X-RateLimit-Remaining: 0

Retry-After каже, через скільки секунд повторити. X-RateLimit-* - поширена (хоч і нестандартна) практика; IETF працює над стандартними заголовками RateLimit і RateLimit-Policy.

У Laravel:

RateLimiter::for('api', fn (Request $request) =>
    Limit::perMinute(60)->by($request->user()?->id ?: $request->ip())
);

Лічильники мають жити в спільному сховищі (Redis), інакше на кількох серверах кожен рахуватиме своє.

Клієнтам - обробляти 429 з експоненційною затримкою й випадковим розкидом (jitter), а не повторювати одразу. Шари захисту: ліміти на рівні CDN/WAF відсікають грубі атаки ще до застосунку, а застосунок обмежує за бізнес-логікою.

Докладніше в документації: RFC 6585: код 429

BOLA (Broken Object Level Authorization, раніше IDOR - Insecure Direct Object Reference) - API перевіряє, що користувач автентифікований, але не перевіряє, чи має він доступ саме до цього об'єкта.

// вразливо: будь-який автентифікований користувач отримає будь-яке замовлення
Route::get('/api/orders/{order}', fn (Order $order) => new OrderResource($order))
    ->middleware('auth:sanctum');

Зловмисник змінює /api/orders/1041 на /api/orders/1040 - і бачить чуже замовлення. Ідентифікатори в API на виду, перебрати їх - справа скрипта.

Чому це вразливість №1 у OWASP API Top 10:

  • у кожному ендпойнті з ідентифікатором треба окремо не забути перевірку;
  • автоматичні сканери її погано знаходять - технічно запит коректний;
  • тести зазвичай перевіряють «власник бачить своє», а не «чужий не бачить».

Захист у Laravel:

1. Політики:

public function show(Order $order): OrderResource
{
    $this->authorize('view', $order);   // OrderPolicy::view
    return new OrderResource($order);
}

// або на маршруті
->can('view', 'order');

2. Пошук через власника - чужий запис просто не знайдеться:

$order = $request->user()->orders()->findOrFail($id);

3. Вкладені маршрути з обмеженням: scopeBindings() - дочірній запис має належати батьківському.

4. Списки - теж об'єкти: GET /api/orders?user_id=5 не повинен повертати замовлення іншого користувача. Фільтр за власником - на сервері, а не з параметра.

Чого не робити:

  • покладатися на непередбачувані ID (UUID замість автоінкременту). UUID ускладнює перебір, але не замінює перевірку: ідентифікатор може потрапити в URL, лог, лист;
  • перевіряти лише в інтерфейсі (прихована кнопка) - API викликають напряму;
  • довіряти ідентифікатору з тіла запиту ("owner_id": 5) - власника визначають з автентифікації.

Тест на кожен ендпойнт з ідентифікатором:

it('forbids viewing another user\'s order', function () {
    $order = Order::factory()->create();
    Sanctum::actingAs(User::factory()->create());

    $this->getJson("/api/orders/{$order->id}")->assertForbidden();
});

Відповідь 403 чи 404: 404 не підтверджує, що об'єкт існує, - для чутливих даних це краще.

Докладніше в документації: OWASP API1:2023 - BOLA

JWT (JSON Web Token) - токен з трьох частин у Base64URL, розділених крапками: заголовок.дані.підпис.

eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiI0MiIsImV4cCI6MTc2MDAwMDAwMH0.Sfl...
  • заголовок - алгоритм підпису (alg);
  • дані (claims) - sub (користувач), exp (термін дії), iss, aud, ролі тощо;
  • підпис - гарантує, що дані не змінено.

Ключова властивість: сервер може перевірити токен без звернення до бази - достатньо ключа. Звідси популярність у мікросервісах.

Що варто розуміти:

  • JWT не шифрований (якщо це не JWE) - будь-хто прочитає дані, декодувавши Base64. Не кладіть туди персональних даних і секретів;
  • підпис ≠ секретність: підпис захищає від зміни, а не від читання.

Типові вразливості (RFC 8725 описує найкращі практики):

  • alg: none - бібліотека, що приймає непідписані токени. Сервер має жорстко задавати дозволені алгоритми, а не брати їх із заголовка токена;
  • плутанина алгоритмів: сервер очікує RS256 (асиметричний), а зловмисник підписує HS256, використавши публічний ключ як секрет. Захист - той самий: явний список алгоритмів;
  • слабкий секрет HS256 - коротку фразу перебирають офлайн;
  • відсутня перевірка exp, aud, iss - токен з іншого сервісу чи прострочений приймається;
  • неможливість відкликання: stateless-токен діє до exp, навіть якщо користувач вийшов чи його заблоковано. Ліки - короткий термін (5-15 хвилин) + refresh-токен, або чорний список (jti) - що повертає звернення до сховища;
  • зберігання в localStorage - крадіжка через XSS.

Коли JWT не потрібен: для власного застосунку з одним бекендом - звичайна сесія чи непрозорий токен у базі (як Sanctum) простіші: відкликання миттєве, у токені немає даних, бібліотеки не потрібні.

Коли доречний: кілька сервісів перевіряють один токен без спільної бази; OAuth/OpenID Connect (ID-токени - це JWT); короткоживучі підписані посилання.

У Laravel: Sanctum використовує непрозорі токени з хешем у базі; Passport (OAuth2-сервер) видає JWT-токени доступу.

Докладніше в документації: RFC 8725: найкращі практики JWT

GET /api/export?api_token=sk_live_9f8a...

Токен у рядку запиту «протікає» в місця, які ніхто не захищає як сховище секретів:

  • журнали доступу вебсервера, балансувальника, CDN, проксі - URL записується повністю;
  • історія браузера й закладки;
  • заголовок Referer - при переході з такої сторінки на інший сайт URL може піти туди;
  • системи моніторингу й аналітики (Sentry, APM), що записують URL запитів;
  • кеш проксі - відповідь може закешуватися за URL з токеном;
  • знімки екрана й повідомлення в чатах підтримки.

RFC 6750 прямо не рекомендує передавати bearer-токени в параметрах URL, крім випадків, коли інших варіантів немає.

Правильно - заголовок:

Authorization: Bearer sk_live_9f8a...

Якщо URL без токена неможливий (завантаження файлу за посиланням, WebSocket у браузері, вбудовування зображення) - короткоживучий одноразовий токен чи підписаний URL з терміном дії, прив'язаний до конкретного ресурсу:

URL::temporarySignedRoute('exports.download', now()->plus(minutes: 10), ['export' => $export]);

Що ще не повинно потрапляти в логи API:

  • заголовки Authorization, Cookie, X-Api-Key;
  • паролі, коди підтвердження, одноразові коди 2FA (типово - поля password, token, code у тілі);
  • номери карток, персональні документи, медичні дані;
  • повні тіла відповідей із персональними даними.

Як це забезпечити:

  • маскування в логах централізовано (процесор логів, фільтр полів у Sentry/Telescope), а не «не забути» в кожному місці;
  • у Laravel - $hidden для серіалізації моделей, #[\SensitiveParameter] для параметрів функцій (значення не з'явиться в стеку винятку), налаштування dontFlash для полів, що не повертаються у форму після помилки;
  • токени з префіксом (sk_live_...) - їх легше знайти й замаскувати сканерами секретів. Sanctum підтримує префікс через token_prefix.

Якщо секрет усе ж потрапив у лог чи репозиторій - вважати його скомпрометованим і відкликати, а не лише видалити запис.

Докладніше в документації: RFC 6750: bearer-токени

Якщо передати в колекцію ресурсів пагінатор, 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: можливості токенів

Питання рівня Middle з реальних технічних співбесід - 35 питань у 7 темах, розібраних із відповідями. Нижче - теми цього рівня та сусідні рівні, якщо хочете звузити або розширити підготовку.

Інші рівні
Junior 35 Senior 30

Готуєтесь до співбесіди не просто так: зараз на сайті 78 відкритих вакансій рівня Middle. Переглянути вакансії