I’ve shipped Laravel APIs both ways — spec first and code first. Code-first feels faster for about two days. Then the Slack messages start. “What does this endpoint return?” “Why is the error format different here?” “Which fields are required?”
Design-first kills those conversations before they happen. You define the contract, get buy-in, then build to spec. Here’s how I do it in Laravel.
Why Bother with Design-First?
Three reasons. First, your frontend team can build against mocks while you write real logic. Parallel work, real velocity. Second, the spec becomes the single source of truth — not someone’s memory of a standup. Third, bugs surface during review, not during integration testing at 11 PM.
Step 1: Define Your API Contract
I write the OpenAPI spec before touching Artisan. Every endpoint, every status code, every field. It forces you to think about the API from the consumer’s perspective — which is the whole point.
Here’s what a basic user endpoint looks like:
openapi: 3.0.3
info:
title: "User API"
version: "1.0.0"
paths:
/api/users/{id}:
get:
summary: Retrieve user by ID
parameters:
- in: path
name: id
required: true
schema:
type: integer
responses:
"200":
description: User retrieved successfully
"404":
description: User not found
Step 2: Review the Spec with Your Team
This is the part people skip. Don’t skip it.
Get frontend, backend, QA, and product in a room (or a thread). Walk through the spec. Let people poke holes. A 30-minute review here saves days of rework later. Iterate until everyone treats this document as the contract. Because that’s what it is.
Step 3: Scaffold Routes and Controllers
Once the spec is locked, I scaffold immediately. The goal: get something the frontend team can hit right away, even if the responses are fake.
Create a controller:
php artisan make:controller Api/UserController
Define routes in routes/api.php:
use App\Http\Controllers\Api\UserController;
Route::get('/users/{id}', [UserController::class, 'show']);
Implement placeholder methods:
// UserController.php
public function show(int $id)
{
// Temporary mock response
return response()->json([
'id' => $id,
'name' => 'Mock User',
]);
}
Now the frontend team has a live endpoint. They don’t need to wait for your database queries. This is where parallel development actually works.
Step 4: Implement Real Business Logic
Replace the mocks with real code. Nothing fancy — just follow what the spec says.
use App\Models\User;
public function show(int $id)
{
$user = User::find($id);
if (!$user) {
return response()->json(['error' => 'User not found'], 404);
}
return response()->json($user, 200);
}
I keep the spec open in a split pane while I write this. Every status code, every field name — it should match exactly. Drift between spec and code is where integration bugs come from.
Step 5: Validation and Error Handling
Laravel’s validation is good out of the box. Use it. The key thing: your error format needs to be consistent across every endpoint. Clients shouldn’t have to guess whether errors come back as errors, error, or message.
use Illuminate\Support\Facades\Validator;
public function store(Request $request)
{
$validator = Validator::make($request->all(), [
'name' => 'required|string|max:255',
'email' => 'required|email|unique:users',
]);
if ($validator->fails()) {
return response()->json(['errors' => $validator->errors()], 400);
}
$user = User::create($request->validated());
return response()->json($user, 201);
}
Pick a format, document it in the spec, stick with it everywhere.
Step 6: Write Tests Against the Contract
Your tests should verify that the implementation matches the spec. Not just “does it work” — “does it work the way we promised.”
// tests/Feature/UserApiTest.php
public function test_retrieve_existing_user_successfully()
{
$user = User::factory()->create();
$response = $this->getJson("/api/users/{$user->id}");
$response->assertStatus(200)
->assertJson([
'id' => $user->id,
'email' => $user->email,
]);
}
public function test_retrieve_nonexistent_user_returns_404()
{
$response = $this->getJson("/api/users/9999");
$response->assertStatus(404)
->assertJson([
'error' => 'User not found',
]);
}
Wire these into CI. Every push validates the contract. If someone changes a response shape without updating the spec, the pipeline catches it.
Step 7: Generate and Maintain Documentation
I use L5-Swagger to generate docs from the OpenAPI spec:
composer require darkaonline/l5-swagger
php artisan l5-swagger:generate
The documentation stays in sync because it comes from the same spec you designed in step one. No manual wiki pages going stale. No “I think it works like this.”
Where This Pays Off
The upfront cost is real — writing a spec before code feels slow. But I’ve watched teams burn entire sprints debugging integration issues that a 20-line YAML change would have prevented.
Design-first isn’t about process for its own sake. It’s about making the boring parts predictable so you can spend your time on the interesting problems.