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

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

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

8 питань

Найпростіше - повернути масив або модель: Laravel сам віддасть JSON із правильним заголовком.

public function show(Post $post)
{
    return $post;                       // JSON усієї моделі
}

public function stats()
{
    return ['total' => Post::count()];  // JSON масиву
}

Чому цього замало. Модель віддає всі свої поля, тож у відповідь потрапляє те, чого там бути не повинно, а перейменування колонки мовчки змінює формат API для всіх клієнтів.

API Resource - це шар між моделлю та JSON, який описує форму відповіді явно:

php artisan make:resource PostResource
class PostResource extends JsonResource
{
    public function toArray(Request $request): array
    {
        return [
            'id' => $this->id,
            'title' => $this->title,
            'published_at' => $this->published_at?->toIso8601String(),
            'author' => new UserResource($this->whenLoaded('author')),
        ];
    }
}

Використання:

public function show(Post $post)
{
    return new PostResource($post);
}

public function index()
{
    return PostResource::collection(Post::paginate());
}

collection() разом із пагінацією сам додає блоки links і meta.

Що це дає: структура відповіді описана в одному місці, поля з бази не протікають назовні, дати мають єдиний формат, а whenLoaded() додає звʼязок лише тоді, коли його справді завантажили - і тим самим не створює N+1.

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

Ключ data. Ресурс, повернений з контролера, загортається в об'єкт з ключем data:

{ "data": { "id": 1, "name": "Olena" } }

Обгортка дає місце для метаданих поруч з даними і захищає від вразливості старих браузерів з JSON-масивом на верхньому рівні.

public static $wrap = 'user';            // власний ключ для цього ресурсу
JsonResource::withoutWrapping();          // вимкнути глобально (AppServiceProvider)

withoutWrapping() не діє на пагіновані відповіді: їм data потрібен, бо поруч ідуть links і meta.

Метадані верхнього рівня:

// у класі ресурсу - щоразу, коли ресурс є кореневим
public function with(Request $request): array
{
    return ['meta' => ['api_version' => '2026-10']];
}

// разово, з контролера
return UserResource::make($user)->additional(['meta' => ['cached' => false]]);

with() додається лише для кореневого ресурсу, не для вкладених.

Колекції:

return UserResource::collection(User::paginate(20));

Пагінована колекція автоматично отримує links (first, last, prev, next) і meta (current_page, total...). Для простої колекції - лише data.

Власний клас колекції - коли самій колекції потрібна логіка:

final class UserCollection extends ResourceCollection
{
    public function toArray(Request $request): array
    {
        return [
            'data' => $this->collection,
            'summary' => ['active' => $this->collection->where('active', true)->count()],
        ];
    }
}

Керування HTTP-відповіддю:

return UserResource::make($user)
    ->response()
    ->setStatusCode(201)
    ->header('Location', route('users.show', $user));

Або в ресурсі - withResponse(Request $request, JsonResponse $response) для заголовків, що потрібні щоразу.

Типові помилки:

  • подвійна обгортка: toArray() повертає ['data' => [...]] - отримаєте data.data;
  • ключі колекції: за замовчуванням вони перенумеровуються; щоб зберегти, - public $preserveKeys = true;
  • формат для клієнтів - це контракт: вимкнення обгортки чи зміна $wrap на робочому API ламає всіх клієнтів, тож такі рішення приймають до першого релізу.

Докладніше в документації: API-ресурси: обгортання даних

Через умовні методи ресурсу - вони не роблять запитів самі, а лише перевіряють, що вже завантажено.

public function toArray(Request $request): array
{
    return [
        'id'             => $this->id,
        'title'          => $this->title,
        'author'         => new UserResource($this->whenLoaded('author')),
        'comments_count' => $this->whenCounted('comments'),
        'is_featured'    => $this->when($request->user()?->isEditor(), $this->is_featured),
        $this->mergeWhen($request->user()?->isAdmin(), [
            'internal_notes' => $this->internal_notes,
        ]),
    ];
}
  • whenLoaded('author') - поле з'явиться, лише якщо контролер зробив with('author');
  • whenCounted('comments') - після withCount('comments');
  • when($condition, $value) - поле зникає з відповіді зовсім, якщо умова хибна;
  • mergeWhen() - кілька полів за однією умовою.

Навіщо так: контролер вирішує, що завантажити для конкретного ендпойнта, а ресурс просто відображає. Без whenLoaded звернення $this->author в ресурсі робить запит на кожен елемент колекції - класичне N+1.

Права й приховані поля: when() з перевіркою ролі не дає віддати внутрішні поля звичайному клієнту - корисніше, ніж $hidden у моделі, бо залежить від того, хто питає.

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

JsonApiResource формує відповіді за специфікацією JSON:API, тож структуру не треба писати вручну.

php artisan make:resource PostResource --json-api
class PostResource extends JsonApiResource
{
    public $attributes = ['title', 'body', 'created_at'];

    public $relationships = ['author', 'comments'];
}

Що ресурс робить сам:

  • структура data з type, id, attributes, relationships;
  • included для запитаних зв'язків - кожен об'єкт один раз, навіть якщо на нього посилаються кілька записів;
  • розріджені набори полів: ?fields[posts]=title,created_at - лише потрібні атрибути;
  • включення: ?include=author - зв'язки потрапляють у відповідь лише на запит;
  • заголовок Content-Type: application/vnd.api+json.

Що він не робить: не розбирає фільтри й сортування з запиту - для цього документація радить spatie/laravel-query-builder.

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

Пам'ятати про N+1: include з запиту має відповідати жадібному завантаженню в контролері; includePreviouslyLoadedRelationships() віддає вже завантажені зв'язки й без параметра.

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

Idempotency (ідемпотентність) - багаторазове виконання операції дає той самий результат, що й однократне. Критично для платежів і повторів завдань у чергах (де доставка «at least once»).

Реалізація для API - idempotency key:

$key = $request->header('Idempotency-Key');

return Cache::lock("idem:$key")->block(5, function () use ($key) {
    if ($cached = Cache::get("idem:result:$key")) {
        return $cached; // повернути попередній результат
    }
    $result = $this->charge(); // виконати один раз
    Cache::put("idem:result:$key", $result, now()->addDay());
    return $result;
});

Для завдань: перевірка «вже оброблено» за унікальним ключем, ShouldBeUnique, або БД-обмеження, що відсікають дублі.

Версіонування дозволяє розвивати API, не ламаючи наявних клієнтів. Стратегії:

URI versioning (найпоширеніше) - версія в шляху:

Route::prefix('v1')->group(base_path('routes/api_v1.php'));
Route::prefix('v2')->group(base_path('routes/api_v2.php'));

Header/Media-type versioning - Accept: application/vnd.app.v2+json. Чистіші URL, але складніше тестувати.

Практики:

  • Окремі неймспейси контролерів і API Resources на версію (V1\PostResource, V2\PostResource).
  • Бізнес-логіку виносити в спільні Action/Service, щоб не дублювати між версіями.
  • Політика deprecation: підтримувати стару версію певний строк, повертати заголовки Deprecation/Sunset.

Докладніше в документації: API Resources (версіонування)

  • Ресурсна модель URL: іменники в множині (/posts, /posts/{id}/comments), дія - через HTTP-метод, а не в URL.
  • Коректні статус-коди: 200/201/204, 422 (валідація), 401/403, 404, 429.
  • API Resources для відповіді - щоб відв'язати JSON від схеми БД і контролювати формат.
  • Версіонування (/v1) із самого старту.
  • Пагінація, фільтрація, сортування через query-параметри; не віддавати все одразу.
  • Consistent error format - єдина структура помилок (Laravel дає { "message": ..., "errors": {...} } для 422).
  • Автентифікація через Sanctum/Passport, rate limiting на маршрутах.
  • Idempotency для небезпечних повторюваних операцій (платежі).
  • Документація (OpenAPI/Scribe) і контрактні тести.

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

Три варіанти з різною ціною.

paginate() - номери сторінок і загальна кількість:

{ "data": [...], "meta": { "current_page": 3, "last_page": 120, "total": 2400 }, "links": {...} }

Ціна: додатковий COUNT(*) і OFFSET, що повільнішає з номером сторінки. Підходить для адмінок і невеликих таблиць, де потрібен перехід на сторінку N.

simplePaginate() - лише «далі/назад», без COUNT(*). Дешевше, але OFFSET лишається.

cursorPaginate() - курсор від останнього запису:

select * from posts where id > 1500 order by id limit 21
  • однаково швидко на будь-якій глибині (з індексом на колонках сортування);
  • не губить і не дублює записи, коли між запитами додаються нові;
  • але немає номерів сторінок і загальної кількості, а сортування має бути за унікальною комбінацією колонок.

Для стрічок, нескінченного прокручування, синхронізації й вивантаження - курсор.

Що ще важливо в API:

  • обмежити per_page зверху (min($request->integer('per_page', 20), 100)), інакше клієнт попросить мільйон;
  • стабільне сортування з id останнім ключем - інакше записи з однаковою датою «стрибають» між сторінками;
  • якщо загальна кількість потрібна на великій таблиці - кешувати її чи віддавати приблизну.

Докладніше в документації: Курсорна пагінація чи за зміщенням