Every Laravel team that ships an API ends up with the same two problems: the documentation drifts from the code within a sprint, and the consumers (your mobile app, a partner, your own React front end) hand-roll clients that break silently when a field is renamed. Both problems have the same fix — treat an OpenAPI description as a build artefact generated from the code, then test against it and generate clients from it.
This tutorial sets that up end to end on Laravel 13: generate OpenAPI 3.1 from your existing controllers, serve interactive docs, fail CI when the implementation drifts from the spec, and emit a typed TypeScript client.
The approach: code-first, not spec-first
There are two schools. Spec-first writes the YAML by hand and generates server stubs; it works well for large multi-team contracts but is heavy for most product teams. Code-first derives the description from the routes, form requests and API resources you already have. In Laravel, code-first wins because the framework already carries most of the type information: route definitions, validation rules, resource shapes and enum-backed casts.
The risk with code-first is that the generated spec documents what you meant, not what the app actually returns. We close that gap with contract tests later in this guide.
Generate the description with Scramble
composer require dedoc/scramble
Out of the box Scramble scans routes/api.php and exposes the document at /docs/api.json with a UI at /docs/api. Publish the config to control what is included:
php artisan vendor:publish --tag=scramble-config
// config/scramble.php
return [
'api_path' => 'api',
'info' => [
'version' => '1.0.0',
'description' => 'Public API for the Acme platform.',
],
'servers' => [
'Production' => 'https://api.acme.test/api',
'Staging' => 'https://staging.acme.test/api',
],
];
By default the docs routes are restricted to local environments. To expose them on staging behind auth, register a gate in a service provider:
use Dedoc\Scramble\Scramble;
use Illuminate\Support\Facades\Gate;
public function boot(): void
{
Gate::define('viewApiDocs', function (?User $user) {
return app()->environment('local') || $user?->isStaff();
});
}
Make your code carry the types
Scramble infers more when your code is explicit. Four changes pay for themselves immediately.
1. Type the request with a FormRequest. Validation rules become the request body schema, including required fields, formats and enums.
class StoreInvoiceRequest extends FormRequest
{
public function rules(): array
{
return [
'customer_id' => ['required', 'uuid', 'exists:customers,id'],
'currency' => ['required', Rule::enum(Currency::class)],
'due_at' => ['required', 'date_format:Y-m-d'],
'lines' => ['required', 'array', 'min:1'],
'lines.*.description' => ['required', 'string', 'max:255'],
'lines.*.amount_cents' => ['required', 'integer', 'min:1'],
];
}
}
2. Type the response with an API Resource and a return type.
class InvoiceController extends Controller
{
/**
* Create an invoice.
*
* Creates a draft invoice and returns it. The invoice is not sent
* until you call the send endpoint.
*
* @throws \Illuminate\Validation\ValidationException
*/
public function store(StoreInvoiceRequest $request): InvoiceResource
{
return InvoiceResource::make(
Invoice::createFromRequest($request->validated())
);
}
}
The docblock summary and description land in the operation; the return type gives the 200 schema.
3. Annotate the resource. PHPDoc on toArray removes guesswork:
class InvoiceResource extends JsonResource
{
/**
* @return array{
* id: string,
* status: 'draft'|'sent'|'paid'|'void',
* currency: string,
* total_cents: int,
* due_at: string,
* customer: CustomerResource,
* }
*/
public function toArray($request): array
{
return [
'id' => $this->id,
'status' => $this->status->value,
'currency' => $this->currency->value,
'total_cents' => $this->total_cents,
'due_at' => $this->due_at->toDateString(),
'customer' => CustomerResource::make($this->whenLoaded('customer')),
];
}
}
4. Declare the security scheme once. For Sanctum bearer tokens:
use Dedoc\Scramble\Support\Generator\OpenApi;
use Dedoc\Scramble\Support\Generator\SecurityScheme;
Scramble::configure()->withDocumentTransformers(function (OpenApi $openApi) {
$openApi->secure(SecurityScheme::http('bearer'));
});
Endpoints behind the auth:sanctum middleware are then marked as requiring a token, and the try-it-out panel gains an authorise box.
Commit the artefact and diff it in CI
Export the document to a file and treat changes to it as reviewable:
php artisan scramble:export --path=openapi.json
Add a CI step that regenerates it and fails if the working tree is dirty:
- name: Check API spec is up to date
run: |
php artisan scramble:export --path=openapi.json
git diff --exit-code openapi.json
Now every PR that changes a request or resource shows the API change in the diff. Reviewers see "this response field became nullable" without reading the controller. This single step catches more accidental breaking changes than any amount of documentation discipline.
For an explicit breaking-change gate, run a spec diff tool against the base branch:
- name: Detect breaking API changes
run: npx @redocly/cli diff origin/main:openapi.json openapi.json
Prove the implementation matches the spec
A generated spec can still be wrong — a controller returns a 422 you never documented, or a field is null in production but typed as a string. Validate real responses against the document with Spectator:
composer require --dev hotmeteor/spectator
php artisan vendor:publish --provider="Spectator\SpectatorServiceProvider"
SPEC_PATH=./openapi.json
SPEC_TYPE=json
Then assert against the contract in your existing feature tests:
use Spectator\Spectator;
beforeEach(fn () => Spectator::using('openapi.json'));
it('matches the documented contract for creating an invoice', function () {
$user = User::factory()->create();
$customer = Customer::factory()->create();
actingAs($user)
->postJson('/api/invoices', [
'customer_id' => $customer->id,
'currency' => 'GBP',
'due_at' => now()->addDays(14)->toDateString(),
'lines' => [
['description' => 'Consulting', 'amount_cents' => 150000],
],
])
->assertValidRequest()
->assertValidResponse(201);
});
it('documents its validation failures', function () {
actingAs(User::factory()->create())
->postJson('/api/invoices', [])
->assertValidResponse(422);
});
assertValidRequest() catches tests that send payloads your own spec forbids; assertValidResponse() catches undocumented or mistyped fields. Run these in the same suite as the rest of your feature tests — the cost is a few milliseconds per assertion.
A practical rule we apply on client projects: every public endpoint needs at least one happy-path contract test and one error-path contract test. The error paths are where undocumented shapes hide.
Generate a typed client
With a trustworthy document, consumers stop hand-writing clients. For a TypeScript front end:
npx openapi-typescript openapi.json -o resources/js/types/api.d.ts
That gives you types only, which pairs well with your existing fetch wrapper:
import type { paths } from '@/types/api';
type CreateInvoiceBody =
paths['/invoices']['post']['requestBody']['content']['application/json'];
type Invoice =
paths['/invoices']['post']['responses']['201']['content']['application/json'];
export async function createInvoice(body: CreateInvoiceBody): Promise<Invoice> {
const res = await fetch('/api/invoices', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify(body),
});
if (!res.ok) throw new ApiError(res.status, await res.json());
return res.json();
}
If you want a full client with methods rather than types, openapi-generator emits SDKs for TypeScript, Swift, Kotlin, Python and more from the same file — useful when a partner integrates with you and you would rather send them a package than a PDF.
Wire the generation into package.json so types regenerate whenever the spec changes:
{
"scripts": {
"api:types": "openapi-typescript openapi.json -o resources/js/types/api.d.ts"
}
}
Then let tsc in CI fail the build when the front end uses a field the API no longer returns. That is the whole point: a renamed column becomes a compile error instead of a support ticket.
Versioning and deprecation
Once a client depends on your document, changes need a policy:
- Additive changes (new optional field, new endpoint) ship any time.
- Breaking changes (removed field, narrowed type, new required input) need a new version prefix —
/api/v2/...with the old routes kept alive — or a sunset window. - Mark endpoints you intend to remove with
@deprecatedin the docblock; Scramble carries that into the spec and most UIs render it clearly. - Announce removals with the
DeprecationandSunsetresponse headers so machines, not just humans, can notice.
A short checklist
- Install Scramble; restrict the docs route by gate.
- Add FormRequests, resource return types and
@return array{...}annotations to every public endpoint. - Export
openapi.jsonand fail CI on an uncommitted diff. - Add Spectator contract assertions to happy paths and error paths.
- Generate client types in CI and typecheck the front end against them.
- Write down the versioning policy before the first external consumer arrives.
None of this is glamorous, and all of it is cheaper than discovering in production that a mobile release has been parsing a field you deleted three weeks ago.
If your Laravel API needs documenting, versioning or hardening before it goes public, that is work we do every week — see our API development in Laravel service or get in touch.