Як побудувати куленепробивні Laravel API за допомогою Schema-First Contract Validation

Перекладено ШІ 0 JustSteveKing 05 серпня, 2026

Більшість Laravel API страждають від розриву між документацією та кодом, що створює простір для критичних помилок. Schema-first підхід перетворює JSON Schema на єдине джерело істини для валідації, DTO та автоматизованих тестів.

Більшість 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 ще до того, як їх помітить хоч один користувач.

Популярні

Інше, що варто прочитати

32 Оновлено 26 червня, 2026

Інтеграція Laravel Socialite з бібліотекою Google Client PHP

Ви хочете навчитися, як інтегрувати Google OAuth у вашому проекті Laravel, використовуючи Socialite? Дізнайтеся, як налаштувати доступ до сервісів Google, таких як Календар, у нашій сьогоднішній статті

14 Оновлено 26 червня, 2026

Локальні моделі та їх скоупи в Laravel за допомогою атрибута Scope

В Laravel 12 ми отримали можливість використовувати новий підхід для визначення локальних скоупів у моделях Eloquent. Дізнайтеся, як новий атрибут #[Scope] спрощує цей процес і зберігає ваші назви методів незмінними

19 Оновлено 26 червня, 2026

Використання штучного інтелекту для управління перекладами в Laravel

Досліджуйте нові можливості локалізації вашого Laravel-додатку з пакунками, які використовують штучний інтелект, такими як ChatGPT та Claude. Які рішення можуть спростити ваш процес перекладу та зробити його більш точним? Читайте далі, щоб дізнатися більше!