Building Bulletproof Laravel APIs using Schema-First Contract Validation
Most Laravel APIs I review have the same shape. There is a controller, there is a FormRequest, and somewhere in a folder nobody has opened in four months there is an OpenAPI file that stopped being true around the third sprint. The validation rules and the documentation describe two different APIs, and the only way to find out which one is real is to send a request and see what comes back.
That gap is not a documentation problem. It is an architecture problem. We treat the contract as an output of the code, something we generate afterwards to keep consumers happy, when the contract is the one part of an API that consumers actually depend on. They do not depend on your Eloquent models. They do not depend on your Action classes. They depend on the shape of what they send you and the shape of what you send back.
Schema-first flips that relationship. You write the contract first, and everything else in the request response lifecycle is derived from it or checked against it. The schema validates the incoming payload, the schema shapes the response, and the schema is what your test suite asserts against. One source of truth, three consumers.
The problem with FormRequests as your only guard
I want to be clear that I like FormRequests. They are a good boundary. The issue is what they do not do.
Take a fairly ordinary request class:
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'], ]; }}Now send this body:
{ "email": "steve@example.com", "company": "Example Ltd", "source": "web", "is_admin": true, "score": 100, "internal_notes": "ignore this"}That request passes validation. Laravel does not care about the extra keys, because rules describe what must be present, not what is permitted. If a later refactor introduces a mass assignment path, or someone reaches for $request->all() instead of $request->validated(), those extra keys are suddenly live data in your database.
There is a subtler cost too. A consumer sending is_admin gets a 201 back and reasonably concludes the field is supported. They have no way to know it was silently discarded. You have created an undocumented, unsupported, entirely imaginary part of your API, and you did it by returning success.
A contract closes that door. In JSON Schema terms it is a single line:
{ "additionalProperties": false}Anything the contract does not name is rejected. Not ignored, rejected, with a response that tells the client exactly which field was not understood.
Writing the contract first
I keep contracts as JSON Schema documents under contracts/, versioned alongside the routes they describe. If you are already publishing OpenAPI, the request and response schemas live inside your OpenAPI document and you extract them at build time. Either way the file is the artefact you edit by hand, and the PHP is what follows.
Here is the request contract for storing a lead:
{ "$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 } }}Writing this first changes the conversation you have before you write any code. You are forced to decide whether notes is nullable or absent, whether source is an open string or a closed set, and what happens when a client sends something you have not thought about. Those are design decisions. Making them in a schema file takes ten minutes. Making them in production takes a deprecation cycle.
Structuring the lifecycle around DTOs
The rule I hold to is that every side of the exchange gets its own object. Requests live in app/Http/Payloads, responses live in app/Http/Responses, and neither of them knows about Eloquent.
app/Http/ Payloads/Leads/ StoreLeadPayload.php Requests/Leads/V1/ StoreLeadRequest.php Responses/Leads/ LeadResponse.php LeadCollectionResponse.php Middleware/ ValidatesContract.phpThe payload is the typed representation of a validated request body. It carries no behaviour beyond construction and serialisation:
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, ]; }}Past this point nothing in the application touches the request. The Action receives a payload, not an array, not a FormRequest, and certainly not the container resolved request singleton. That is the boundary doing its job.
Enforcing the contract at the edge
There are two ways to wire the schema in. You can validate inside the FormRequest, or you can validate before the FormRequest is ever resolved. I prefer the second, because a contract violation is not the same class of failure as a business rule violation, and I want to be able to tell them apart in logs.
Install a schema validator:
composer require opis/json-schemaThen a middleware that resolves the contract for the current route and checks the raw body against it:
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); }}The validator is registered once, with a resolver pointing at your contracts directory, so schemas are loaded and cached by $id rather than read from disk on every 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; });}Applying it to a route is then a matter of naming the contract:
Route::post('/leads', V1\StoreController::class) ->middleware('contract:v1/leads/store-request');The FormRequest still exists, and it still earns its place. It handles the things a schema genuinely cannot: uniqueness checks against the database, authorisation, rules that depend on the authenticated user. What it no longer does is describe the shape of the payload, because the schema already did that and did it in a format your consumers can read.
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'), ); }}Notice what happened to the rules array. It got small, and everything left in it is something that requires knowledge the schema does not have. That is the split I want.
Failing in a way clients can act on
A contract violation deserves a better response than a generic 422 with a flat error bag. This is where Problem+JSON pays for itself:
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'], ); }}The client gets the schema identifier, the path to the offending field, and a link to documentation that describes the failure. A developer integrating with you can fix a broken payload without opening a support ticket, which is the whole point.
Responses get the same treatment
Requests are the half everyone remembers. Responses are the half that breaks people, because a response shape change ships silently and only surfaces when a mobile client three versions behind starts throwing null pointer exceptions.
Response DTOs live in app/Http/Responses and implement 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, ); }}The controller reads exactly the way you would hope:
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, ); }}And the response contract sits next to the request contract, with additionalProperties set to false on every object in the tree. Leaking an internal field into a response is exactly as much of a breaking change as removing a documented one, and this is what stops it.
Testing against the contract
Here is where schema-first stops being tidy and starts being load bearing. Every feature test asserts against the published contract rather than against a hand written array:
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');});The custom expectation is a thin wrapper around the same validator the middleware uses:
expect()->extend('toMatchContract', function (string $contract): void { $result = app(Validator::class)->validate( data: json_decode($this->value->getContent()), schema: "https://api.example.com/contracts/{$contract}.json", );
expect($result->isValid())->toBeTrue( (new ErrorFormatter())->formatFlat($result->error() ?? '')[0] ?? '', );});Now the contract cannot drift. If someone adds a field to a response DTO without adding it to the schema, the suite fails. If someone tightens the schema without updating the DTO, the suite fails. Documentation and implementation are held together by CI rather than by good intentions, and good intentions have a terrible track record.
I also run a paranoid test that sends deliberate rubbish at every write endpoint:
it('rejects fields outside the contract', function (): void { $this->postJson('/api/v1/leads', [ 'email' => 'steve@example.com', 'company' => 'Example Ltd', 'source' => 'web', 'score' => 100, ])->assertStatus(422);});If that test ever goes green in the wrong direction, something has quietly opened the gate.
What this costs you
I am not going to pretend this is free. You are maintaining schema files as well as PHP classes, and there is a real temptation to generate one from the other to avoid the duplication. Resist it in the direction that matters. Generating PHP from the schema is fine. Generating the schema from PHP puts you back where you started, with a contract that describes whatever the code happens to do today.
There is also a performance consideration. Validating every request body against a schema is not free, though with a resolver caching compiled schemas in memory the cost sits well below the database work in a typical request. Measure it on your own workload before you take my word for it.
The payoff is that your API stops being a thing you discover through experimentation. Consumers read the contract and know what is accepted. Your test suite reads the contract and knows what is correct. Your middleware reads the contract and rejects what is not. Three parties, one file, no drift.
That is what bulletproof actually means here. Not that nothing ever goes wrong, but that when something does go wrong, it goes wrong loudly, in CI, before anybody outside your team notices.