Я щойно дописав API Artisan: A Guide to Building APIs with Laravel і віддаю її безкоштовно. Перш ніж ви зануритеся у 300 сторінок, пропоную стислу версію: поради, патерни та дрібні рішення, що відрізняють API, який просто працює, від продукту, яким розробники справді хочуть користуватися.
Для цього не потрібне потужніше залізо, інший framework чи велика команда. Потрібно лише сфокусувати увагу на правильних речах. Ось принципи, до яких я повертаюся щоразу.
Почніть із правильних метрик
Якщо запитати більшість команд, чи хороший у них API, вони відповідають: «Ну, він працює. Можна зробити запит на endpoint і отримати відповідь». Це необхідна умова, але її недостатньо.
Натомість запитайте себе: чи комфортно з вашим API жити? Чи може розробник прочитати документацію, зрозуміти модель auth, зробити успішний запит і обробити помилку без звернення в сапорт чи вгадування коду статусу? Прірва між «працює» та «комфортно жити» не помітна на ретроспективах спринтів, але вона проявляється всюди: у навантаженні на підтримку, у зірваних дедлайнах інтеграцій та в той момент, коли розробник вирішує написати щось своє «поверх», аніж використовувати ваш інструмент.
Головне зрушення в парадигмі: API — це продукт. У нього є користувачі. Ставтеся до нього як до «деталі реалізації», і він поводитиметься відповідно: змінюватиметься без попередження при внутрішніх правках і буде хаотичним, бо різні люди писали різні частини в різні дні.
Спершу контракт, потім код
Природний шлях — написати handler, повернути дані, а документацію залишити на потім. Це здається ефективним у моменті, але створює проблему: контракт, який не був спроєктований, а просто «стався».
Ось пастка: у вас є таблиця keys із колонкою revoked_at. Ви берете Eloquent model і викликаєте $key->toArray(). У відповіді з'являється поле revoked_at. За кілька тижнів ви розумієте, що назва невдала, і перейменовуєте колонку. Для кожної зовнішньої інтеграції це breaking change, про який ніхто не попередив.
Проєктування структури відповіді до написання query розв'язує цю проблему в зародку. Ви не «проллєте» схему бази даних, якщо спершу визначите, що саме потрібно споживачеві. Питання змінюється з «як мені віддати ці дані?» на «що насправді потрібно розробнику, який викликає цей API?».
Вашу реалізацію можна рефакторити коли завгодно. Але опубліковане ім'я поля живе в чужому коді, який ви не контролюєте. Виносьте рішення щодо контракту наперед. Ціна помилки тут реальна, і платитимуть її інші люди у найгірший момент.
Що таке breaking change насправді
Очевидні речі знають усі: видалення endpoint або перейменування поля. Але реальний список довший і підступніший — навіть «покращення» можуть зламати інтеграцію.
Зміна типу поля (наприклад, expires_at із рядка дати на Unix timestamp). Перетворення nullable поля на обов’язкове. Додавання обов'язкового параметра в запит. Зміна логіки поля без зміни його назви. Зміна форматів або кодів помилок. Перехід з offset pagination на cursor pagination. Огортання колекції в об'єкт meta, якщо раніше клієнти деструктурували масив напряму.
Будь-яка зміна, що здається внутрішньою, — це зобов'язання перед тими, хто покладається на стару поведінку. Вихід не в тому, щоб нічого не змінювати, а в тому, щоб розуміти свої зобов'язання та мати механізм безпечного впровадження змін.
Версіонування з першого дня
Мало хто замислюється, але додавання префікса /v1 до API, який вже працює в продакшені, — це теж breaking change. Саме тому версіонування з першого дня — це не передчасна оптимізація. Це найдешевший момент для його впровадження. Ціна — один сегмент в URL, а вигода — стабільний контракт із першого ж релізу.
Робіть версію явною на всіх рівнях. Розділяйте контролери за Namespace: App\Http\Controllers\Keys\V1. Коли з'явиться v2, вона житиме в Keys\V2. Контролери v1 залишаться недоторканими — ви фізично не зможете внести логіку v2 у стару версію, бо межа чітко прописана в коді.
Для виведення версії з експлуатації використовуйте паттерн Sunset (RFC 8594). Передавайте заголовок Sunset із датою відключення та Deprecation із датою оголошення застарілим. І найголовніше — заголовок Link, що веде на migration guide. Так розробник зможе відразу перейти до інструкцій з оновлення. Моє правило: жодна версія не закривається без попередження мінімум за шість місяців.
REST — це конвенції, а не релігія
Дозвольте зекономити ваш час на суперечках у pull requests. Майже ніхто не впроваджує справжній гіпермедіа-контроль. Більшість «REST API» — це просто HTTP з JSON та певними домовленостями щодо URL. І це нормально. Корисні саме конвенції, а не сліпе слідування архітектурному стилю.
Забудьте про HATEOAS, якщо ви не будуєте універсальний браузер API. Рівень 3 за моделлю Річардсона не є показником якості. API з продуманим стабільним контрактом набагато кращий за ідеально «рестовий» API з незрозумілими помилками.
Краще зосередьтеся на статус-кодах — вони є частиною контракту, і на них будується логіка повторних запитів (retry logic).
201 замість 200 при створенні, щоб клієнт міг зчитати заголовок Location
422 замість 400 для валідації (400 — це пошкоджений запит, 422 — коректний, але невалідний)
403 замість 401, коли користувач автентифікований, але не має прав доступу
Плутанина між 401 та 403 створює реальні баги: клієнт може нескінченно намагатися перелогінитися, хоча йому просто заборонено доступ до ресурсу.
Для операцій, що не вкладаються в CRUD (наприклад, відкликання ключа), використовуйте підшляхи: POST /v1/keys/{id}/revoke. Чи це чистий REST? Ні. Чи це зрозуміло кожному? Так. Це компроміс, на який варто йти.
Робіть контролери нудними
Кожен контролер у моїй книзі — це invokable class з однією дією. Один клас, один метод __invoke, одна відповідальність. IssueController чітко говорить про свою задачу, на відміну від розмитого KeyController.
Декілька порад: посилайтеся на контролери як на класи (не closures), щоб працював route caching. Використовуйте declare(strict_types=1). Маркуйте контролери як final. Тримайте валідацію у Form Requests, щоб не засмічувати метод контролера логікою перевірок.
Найкраща практика в Laravel — патерн payload(). Завдання Form Request не лише провалідувати вхідні дані, а й перетворити їх на типізований, незмінний value object (DTO).
public function payload(): IssueKeyPayload
{
return new IssueKeyPayload(
name: $this->string('name')->toString(),
scopes: $this->collect('scopes')->map(KeyScope::from(...)),
expiresAt: filled($this->input('expires_at'))
? CarbonImmutable::parse($this->string('expires_at')->toString())
: null,
);
}
Оскільки payload() викликається після валідації, ви можете впевнено приводити типи. Контролер отримує готовий об'єкт, передає його в action і повертає resource. Action працює з доменними типами й нічого не знає про HTTP-запит — це дозволяє викликати ту саму логіку з консольної команди чи черги без жодних змін.
Уніфікуйте структуру помилок
«З коробки» Laravel повертає різні формати для помилок валідації, проблем з авторизацією або відсутності моделі. Клієнту доводиться писати окремі обробники для кожного випадку.
Використовуйте стандарт RFC 9457 Problem+JSON для кожної помилки. Єдина структура: type, title, status, detail та розширення errors для валідації. Встановлюйте Content-Type: application/problem+json. У fallback-обробнику повертайте чисту помилку 500 для продакшену, але залишайте full stack traces для розробки.
Унеможливте некоректні стани
Якщо ресурс має життєвий цикл, моделюйте його як state machine на базі enum. Недопустимі переходи мають викликати доменні виключення. Сутність сама має стежити за своїми правилами, а не покладатися на те, що розробник не забуде перевірити поле перед дією.
Ставтеся до доменних виключень (як-от InvalidKeyTransitionException) як до очікуваних результатів. Вони заслуговують на інформативний код 422, а не на загальну помилку 500. Реєструйте їх в exception handler нарівні з системними помилками.
Поважайте межі транзакцій
Транзакції захищають цілісність даних, але вони не всесильні. Запам'ятайте два правила:
Ніколи не робіть зовнішніх HTTP-викликів всередині транзакції. Якщо виклик пройде успішно, а транзакція потім відкотиться, ви отримаєте побічний ефект, про який база даних «не знає». Використовуйте DB::afterCommit() для відправки job та webhooks.
Транзакції також не допоможуть, якщо мережа впаде після того, як ви повернули 201, але до того, як клієнт його отримав. Клієнт повторить запит, і ви можете виконати операцію двічі. Для цього існують idempotency keys. Приймайте заголовок Idempotency-Key, зберігайте першу відповідь і просто повертайте її при повторних запитах із тим самим ключем.
Аудит-лог — це першочергово
Аудит-лог — це не файл логів. Це таблиця в базі даних, куди записуються лише значущі події: виданий ключ, відкликаний доступ, запрошений користувач. Вона має бути суворо append-only: ніяких updated_at, soft deletes чи операцій оновлення в коді. Запис, який можна змінити, — це не аудит, це просто мутабельний рядок.
Дві поради щодо дизайну: зберігайте контекст події у JSONB, бо метадані для різних подій відрізняються. Обов'язково фіксуйте actor_type та actor_id, щоб розуміти, хто виконав дію: людина, API key чи сама система.
Здатність системи відповідати на запитання
Додайте request ID до кожного рядка логів за допомогою middleware.
$requestId = $request->header('X-Request-ID') ?? Str::uuid()->toString();
Log::withContext(['request_id' => $requestId]);
$response = $next($request);
$response->headers->set('X-Request-ID', $requestId);
Тепер усі записи логів у межах одного запиту мають спільний ID. Коли користувач повідомляє про проблему, він може просто надати цей ID з заголовка відповіді, і ви миттєво знайдете весь ланцюжок подій.
Також використовуйте OpenAPI (spec-first підхід), щоб документація не розходилася з реальністю. Запускайте contract tests у CI, щоб випадковий breaking change зупиняв build. Ставтеся до онбордингу як до частини API — надавайте готові колекції запитів замість стіни тексту.
Що все це об’єднує
Патерни — не самоціль. Головне — логіка. Емпатія до споживача як інженерна дисципліна, стабільність як фіча, спостережуваність як архітектурна гарантія. Фреймворки змінюються, але ці принципи дозволять вам приймати правильні рішення в будь-якому проєкті.
Усі ці поради взяті з реального проєкту Portkey, який я розробляю в книзі. Це платформа для управління ключами API з версіонуванням, шарами JWT та HMAC, підписаними webhooks та обмеженням частоти запитів.
Якщо ці нотатки були корисними, у повній книзі кожен пункт розібрано з прикладами коду та аргументацією. Вона безкоштовна. Завантажуйте API Artisan і будуйте API, на які ваші користувачі справді заслуговують.