Більшість Laravel API, з якими я працюю, влаштовані однаково: контролер, FormRequest і десь у нетрях папок — файл OpenAPI, про який усі забули ще на третьому спринті. Правила валідації та документація описують два різні API, а єдиний спосіб дізнатися правду — надіслати запит і подивитися, що прийде у відповідь.
Цей розрив — не проблема документації. Це проблема архітектури. Ми сприймаємо контракт як побічний продукт коду, що створюється «потім» для зручності клієнтів. Проте контракт — це єдина частина API, від якої справді залежать споживачі. Їм байдуже до ваших Eloquent моделей чи Action-класів. Їм важлива лише структура даних, які вони надсилають і отримують.
Підхід Schema-first перевертає цю логіку. Ви спочатку пишете контракт, а все інше в життєвому циклі запиту підлаштовується під нього. Схема валідує вхідні дані, формує відповідь і слугує еталоном для тестів. Одне джерело істини для трьох різних завдань.
Чому FormRequest як єдиний захист — це проблема
Мені подобаються FormRequest, це чудові межі для коду. Проблема лише в тому, чого вони НЕ роблять.
Розглянемо звичайний клас запиту:
final class StoreLeadRequest extends FormRequest
{
public function rules(): array
{
return [
'email' => ['required', 'email', 'max:255'],
'company' => ['required', 'string', 'max:255'],
'source' => ['required', 'string', 'in:web,referral,event'],
];
}
}
Тепер надішлемо такий JSON:
{
"email": "steve@example.com",
"company": "Example Ltd",
"source": "web",
"is_admin": true,
"score": 100,
"internal_notes": "ignore this"
}
Цей запит пройде валідацію. Laravel ігнорує зайві ключі, бо правила описують те, що має бути присутнім, а не те, що дозволено. Якщо згодом хтось використає mass assignment або $request->all() замість $request->validated(), ці «ліві» дані опиняться у вашій базі.
Є й менш очевидна проблема: клієнт, що надіслав is_admin і отримав статус 201, логічно вирішить, що поле підтримується. Він не знатиме, що систему його просто відкинула. Так створюється недокументована, вигадана частина вашого API — і все це через успішну відповідь на некоректний запит.
Контракт вирішує це однією лінією в JSON Schema:
{
"additionalProperties": false
}
Усе, що не вказано в контракті, буде відхилено. Не проігноровано, а саме відхилено з чітким поясненням, яке саме поле система не зрозуміла.
Пишемо контракт першим
Я зберігаю контракти як JSON Schema у папці contracts/ поруч із маршрутами. Якщо ви використовуєте OpenAPI, схеми можна витягувати з основного документа під час збірки. У будь-якому разі, файл схеми — це те, що ви редагуєте вручну, а PHP-код має йому відповідати.
Ось приклад контракту для створення ліда:
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://api.example.com/contracts/v1/leads/store-request.json",
"type": "object",
"additionalProperties": false,
"required": ["email", "company", "source"],
"properties": {
"email": {
"type": "string",
"format": "email",
"maxLength": 255
},
"company": {
"type": "string",
"minLength": 1,
"maxLength": 255
},
"source": {
"type": "string",
"enum": ["web", "referral", "event"]
},
"notes": {
"type": ["string", "null"],
"maxLength": 2000
}
}
}
Такий підхід змушує приймати проектні рішення ще до написання коду. Чи може поле notes бути null? Які значення допустимі для source? Вирішити це в схемі займає 10 хвилин. Виправляти це в проді — означає проходити через болісний цикл депрекації.
Структурування життєвого циклу навколо DTO
Моє правило: кожна сторона обміну даними має свій об'єкт. Запити живуть в app/Http/Payloads, відповіді — в app/Http/Responses, і жоден із них не знає про існування Eloquent.
app/Http/
Payloads/Leads/
StoreLeadPayload.php
Requests/Leads/V1/
StoreLeadRequest.php
Responses/Leads/
LeadResponse.php
LeadCollectionResponse.php
Middleware/
ValidatesContract.php
Payload — це типізоване представлення валідованого тіла запиту. Це простий DTO без логіки:
namespace App\Http\Payloads\Leads;
final readonly class StoreLeadPayload
{
public function __construct(
public string $email,
public string $company,
public string $source,
public ?string $notes = null,
) {}
public function toArray(): array
{
return [
'email' => $this->email,
'company' => $this->company,
'source' => $this->source,
'notes' => $this->notes,
];
}
}
Після цього етапу додаток більше не взаємодіє із сирим запитом. Action отримує Payload, а не масив чи FormRequest. Це і є справжнє розмежування відповідальності.
Контроль контракту на вході
Схему можна підключити двома способами: валідувати всередині FormRequest або ще до того, як він буде створений. Я віддаю перевагу другому варіанту, щоб чітко відрізняти порушення контракту від порушення бізнес-логіки в логах.
Встановлюємо валідатор:
composer require opis/json-schema
Створюємо Middleware, який перевіряє тіло запиту на відповідність контракту:
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Opis\JsonSchema\Validator;
use Symfony\Component\HttpFoundation\Response;
final readonly class ValidatesContract
{
public function __construct(
private Validator $validator,
) {}
public function handle(Request $request, Closure $next, string $contract): Response
{
$result = $this->validator->validate(
data: json_decode($request->getContent() ?: '{}'),
schema: "https://api.example.com/contracts/{$contract}.json",
);
if ($result->hasError()) {
throw new ContractViolationException(
error: $result->error(),
);
}
return $next($request);
}
}
Реєструємо валідатор як синглтон, щоб схеми кешувалися в пам'яті, а не зчитувалися з диска при кожному запиті:
public function register(): void
{
$this->app->singleton(Validator::class, function (): Validator {
$validator = new Validator();
$validator->resolver()->registerPrefix(
prefix: 'https://api.example.com/contracts/',
path: base_path('contracts'),
);
return $validator;
});
}
Застосування до маршруту виглядає так:
Route::post('/leads', V1\StoreController::class)
->middleware('contract:v1/leads/store-request');
FormRequest залишається для того, чого схема не вміє: перевірки унікальності в базі, авторизації або правил, що залежать від поточного користувача. Він більше не описує структуру даних — це вже зроблено в схемі, яку можуть читати ваші клієнти.
final class StoreLeadRequest extends FormRequest
{
public function rules(): array
{
return [
'email' => ['unique:leads,email'],
];
}
public function payload(): StoreLeadPayload
{
return new StoreLeadPayload(
email: $this->string('email')->toString(),
company: $this->string('company')->toString(),
source: $this->string('source')->toString(),
notes: $this->input('notes'),
);
}
}
Масив правил став мінімальним. У ньому залишилося тільки те, що вимагає доступу до бази даних чи стану додатка.
Інформативні помилки для клієнтів
Порушення контракту заслуговує на кращу відповідь, ніж звичайний статус 422. Тут стане в пригоді формат Problem+JSON:
final readonly class ContractViolationResponse implements Responsable
{
public function __construct(
private ValidationError $error,
private string $contract,
) {}
public function toResponse($request): JsonResponse
{
return new JsonResponse(
data: [
'type' => "https://api.example.com/errors/contract-violation",
'title' => 'Request does not match the published contract',
'status' => 422,
'detail' => 'One or more fields were missing, malformed, or not recognised.',
'contract' => $this->contract,
'errors' => (new ErrorFormatter())->format($this->error),
],
status: 422,
headers: ['Content-Type' => 'application/problem+json'],
);
}
}
Клієнт отримує ідентифікатор схеми, шлях до проблемного поля та посилання на документацію. Розробник, що інтегрується з вами, може виправити помилку самостійно, не звертаючись до підтримки.
Такий самий підхід до відповідей
Про запити пам’ятають усі, але саме зміни в структурі відповідей зазвичай ламають клієнтські додатки. Коли мобільний клієнт старої версії отримує несподіваний null, він просто «падає».
Об'єкти відповіді живуть у app/Http/Responses і реалізують інтерфейс Responsable:
namespace App\Http\Responses\Leads;
final readonly class LeadResponse implements Responsable
{
public function __construct(
private Lead $lead,
private int $status = 200,
) {}
public function toResponse($request): JsonResponse
{
return new JsonResponse(
data: [
'data' => [
'type' => 'leads',
'id' => $this->lead->id,
'attributes' => [
'email' => $this->lead->email,
'company' => $this->lead->company,
'source' => $this->lead->source,
'score' => $this->lead->score,
'created_at' => $this->lead->created_at->toIso8601String(),
],
],
],
status: $this->status,
);
}
}
Тепер контролер виглядає максимально лаконічно:
final readonly class StoreController
{
public function __construct(
private CreateLead $action,
) {}
public function __invoke(StoreLeadRequest $request): Responsable
{
return new LeadResponse(
lead: $this->action->handle(
payload: $request->payload(),
),
status: 201,
);
}
}
Контракт відповіді знаходиться поруч із контрактом запиту. Параметр additionalProperties: false гарантує, що жодне внутрішнє поле не потрапить до відповіді випадково.
Тестування на відповідність контракту
Ось де Schema-first показує свою справжню силу. Кожен Feature-тест перевіряє відповідь на відповідність контракту, а не просто порівнює масиви:
it('returns a lead matching the published contract', function (): void {
$response = $this->postJson('/api/v1/leads', [
'email' => 'steve@example.com',
'company' => 'Example Ltd',
'source' => 'web',
]);
$response->assertStatus(201);
expect($response)->toMatchContract('v1/leads/store-response');
});
Це гарантує, що контракт і реалізація ніколи не розійдуться. Якщо ви додасте поле в DTO, але забудете оновити схему — тести впадуть. Документація та код тепер нерозривно пов'язані через CI.
Ціна питання
Я не буду стверджувати, що це дається безкоштовно. Вам доведеться підтримувати і схеми, і PHP-класи. Виникне спокуса генерувати одне з іншого, але не піддавайтеся їй. Генерувати PHP зі схеми — нормально. Генерувати схему з PHP — це шлях назад до контракту, який просто описує те, що код робить сьогодні.
Також є питання продуктивності. Валідація кожного запиту за схемою потребує ресурсів, хоча з кешуванням скомпільованих схем це набагато дешевше, ніж запити до бази даних.
Вигода в тому, що ваше API перестає бути загадкою, яку розгадують експериментами. Споживачі знають, що надсилати. Тести знають, що перевіряти. Middleware відсікає все зайве. Один файл — повна впевненість. Це і є справжня «куленепробивна» архітектура: коли помилки виявляються голосно і в CI ще до того, як їх помітить хоч один користувач.