laravel-openapi maintained by zero-to-prod
Laravel OpenAPI
Generate an OpenAPI 3.0 document from PHP attributes on your controllers, serve it over HTTP, and then verify it is actually true — by validating real responses against it and failing your build when something you declared was never tested.
A schema you hand-write is a claim. This package is built around the idea that a claim nobody checks is worth very little, so it ships three layers:
- Generate —
#[ApiSchema]attributes on controller methods are merged into one document, served at/openapi.json. - Validate the document —
openapi:validatechecks it against the OpenAPI specification. - Validate the behavior — a test trait matches real requests and responses against the document, and
openapi:coveragefails when a declared response was never exercised.
Requirements
- PHP
^8.3 - Laravel 12 or 13
Installation
composer require zero-to-prod/laravel-openapi
The service provider is auto-discovered. Publish the config if you want to change anything:
php artisan vendor:publish --tag=openapi-config
Two optional dev dependencies unlock layers 2 and 3. Neither is needed to generate or serve the document:
# for `openapi:validate`
composer require --dev devizzent/cebe-php-openapi
# for the ValidatesSchema test trait
composer require --dev league/openapi-psr7-validator symfony/psr-http-message-bridge nyholm/psr7
A third unlocks the MCP server, which is unrelated to the three layers and only there for coding agents:
composer require --dev laravel/mcp
Quick start
Annotate a controller method with the OpenAPI fragment that describes it. The array is plain OpenAPI — whatever you write here is what ends up in the document, so anything the specification allows is available to you:
use Illuminate\Http\JsonResponse;
use ZeroToProd\LaravelOpenapi\ApiSchema;
class ShowArticleController
{
#[ApiSchema([
'paths' => [
'/articles/{id}' => [
'get' => [
'operationId' => 'showArticle',
'parameters' => [
[
'name' => 'id',
'in' => 'path',
'required' => true,
'schema' => ['type' => 'string'],
],
],
'responses' => [
'200' => [
'description' => 'The article.',
'content' => [
'application/vnd.api+json' => [
'schema' => [
'type' => 'object',
'required' => ['id', 'title'],
'properties' => [
'id' => ['type' => 'string'],
'title' => ['type' => 'string'],
],
],
],
],
],
],
],
],
],
])]
public function __invoke(string $id): JsonResponse
{
return new JsonResponse(
data: ['id' => $id, 'title' => 'Zero to prod'],
headers: ['Content-Type' => 'application/vnd.api+json'],
);
}
}
GET /openapi.json now serves:
{
"openapi": "3.0.4",
"info": { "title": "JSON:API", "version": "1.0.0" },
"servers": [{ "url": "/" }],
"paths": {
"/articles/{id}": { "get": { "operationId": "showArticle", "...": "..." } },
"/openapi.json": { "get": { "operationId": "getSchema", "...": "..." } }
}
}
Declared paths are relative to servers
OpenAPI resolves every path against the first server URL, which defaults to /. So declare the path the route
actually serves: a route at /articles/{id} is declared as /articles/{id}.
If your whole API sits under a common base, set it once in servers and drop it from every attribute — a route at
/api/articles/{id} with servers of /api is declared as /articles/{id}. The response validator honours the
server URL either way, so both styles are checked against real traffic.
How the document is assembled
SchemaGenerator walks every registered route, reflects the controller method behind it, and merges the paths and
components from each #[ApiSchema] attribute it finds. Routes without the attribute are ignored, so this package
never invents documentation for endpoints you did not describe.
Document-level fields that cannot be derived from routes come from config:
'openapi' => [
'openapi' => '3.0.4',
'info' => ['title' => 'JSON:API', 'version' => '1.0.0'],
'servers' => [['url' => '/']],
],
The merged document is served verbatim. It is deliberately not validated on the way out — an incomplete fragment
should not break the endpoint that would tell you about it. That job belongs to openapi:validate.
Routing
By default the package registers one route: GET /openapi.json, named openapi.schema. Every part is configurable:
| Key | Default | Purpose |
|---|---|---|
openapi.route.enabled |
true |
Register the route at all |
openapi.route.uri |
'openapi.json' |
URI within the prefix |
openapi.route.name |
openapi.schema |
Route name |
openapi.route.prefix |
'' |
Group prefix |
openapi.route.middleware |
['api'] |
Group middleware |
For anything config cannot express — auth, domains, throttling, nested groups — turn the default off and place the route yourself:
// config/openapi.php
'route' => ['enabled' => false, /* ... */],
// routes/api.php
use ZeroToProd\LaravelOpenapi\ApiSchema;
Route::middleware('auth:sanctum')
->prefix('internal')
->group(fn () => ApiSchema::routes());
ApiSchema::routes() registers the route with no prefix or middleware of its own and returns the Route, so you can
keep configuring it. It also accepts an explicit URI and name:
ApiSchema::routes('docs/openapi.json', 'docs.schema')->middleware('throttle:60,1');
Validating the document
php artisan openapi:validate
INFO The generated document is a valid OpenAPI 3.0.4 document (2 paths).
On failure it reports every problem at once and exits 1:
ERROR The generated document is not a valid OpenAPI 3.0.4 document.
⇂ Failed to resolve Reference '#/components/schemas/DoesNotExist' ...
⇂ Operation is missing required property: responses
Dangling $refs are included: references are resolved as well as validated, because a document can be structurally
valid while pointing at components that do not exist.
Validating behavior against the document
This is the layer that turns the schema from a claim into a checked fact. Add the trait to your base TestCase:
use ZeroToProd\LaravelOpenapi\ValidatesSchema;
abstract class TestCase extends BaseTestCase
{
use ValidatesSchema;
}
Then assert against it:
$this->assertMatchesSchema($this->getJson('articles/42'));
The operation is resolved from the request automatically — you never name the path or method. Both the request and the response are checked. Failures name the operation and the keyword that broke:
Body does not match schema for content-type "application/vnd.api+json" for Response [get /articles/{id} 200]
caused by: Keyword validation failed: Required property 'title' must be present in the object
Undeclared status codes are caught too, which is the mirror image of the usual problem — not "the schema lies about the body" but "the endpoint does something the schema never mentions":
OpenAPI spec contains no such operation [/undeclared-status,get,418]
Coverage: catching what you never tested
Response validation only proves the endpoints your tests reach are honest. An operation nobody exercised is exactly
as unverified as it was before you added the assertion. assertMatchesSchema() records every
(path, method, status) it validates, appended as JSON Lines so separate processes and parallel workers can share
one file.
php artisan openapi:coverage --reset && vendor/bin/pest && php artisan openapi:coverage
ERROR 1 of 2 declared responses were never exercised.
⇂ GET /articles/{id} -> 404
Exits 1 when anything is missing, so it works as a CI gate. Granularity is per response, not per operation: a 422
you declare but never exercise is precisely the unverified claim this catches. 2XX ranges and default count as
covered by any matching concrete status.
assertSchemaFullyExercised() is also available for the same check inside a single-process suite.
MCP server
The package ships an MCP (Model Context Protocol) server that exposes this package's own documentation to AI agents,
so an agent writing #[ApiSchema] attributes can read how they are meant to be shaped instead of guessing.
Installation
The MCP server requires laravel/mcp, which is optional — without it, nothing is registered and the rest of the
package behaves exactly as before:
composer require --dev laravel/mcp
There is no install command to run. The service provider registers the server under the laravel-openapi handle when
it boots, so it is ready as soon as the package is installed. Confirm it is there:
php artisan mcp:start laravel-openapi
That starts a stdio server and waits on standard input, which is what an agent attaches to. Press Ctrl+C to exit.
Set up your agents
claude mcp add -s local -t stdio laravel-openapi php artisan mcp:start laravel-openapi
codex mcp add laravel-openapi -- php "artisan" "mcp:start" "laravel-openapi"
gemini mcp add -s project -t stdio laravel-openapi php artisan mcp:start laravel-openapi
1. Create `.cursor/mcp.json` in your project root with the JSON below
2. Open the command palette (`Cmd+Shift+P` or `Ctrl+Shift+P`)
3. Press `enter` on "Open MCP Settings"
4. Turn the toggle on for `laravel-openapi`
{
"mcpServers": {
"laravel-openapi": {
"type": "stdio",
"command": "php",
"args": ["artisan", "mcp:start", "laravel-openapi"]
}
}
}
1. Create `.vscode/mcp.json` in your project root with the JSON below — note the
top-level key is `servers`, not `mcpServers`
2. Open the command palette (`Cmd+Shift+P` or `Ctrl+Shift+P`)
3. Press `enter` on "MCP: List Servers"
4. Arrow to `laravel-openapi` and press `enter`, then choose "Start server"
{
"servers": {
"laravel-openapi": {
"type": "stdio",
"command": "php",
"args": ["artisan", "mcp:start", "laravel-openapi"]
}
}
}
1. Create `.junie/mcp/mcp.json` in your project root with the JSON below, or add
the server through Tools | Junie | MCP Settings to write it for you
2. Press `shift` twice to open the command palette
3. Search "MCP Settings" and press `enter`
4. Check the box next to `laravel-openapi`, then click "Apply"
{
"mcpServers": {
"laravel-openapi": {
"command": "php",
"args": ["artisan", "mcp:start", "laravel-openapi"]
}
}
}
[!NOTE] Every registration above runs
php artisanfrom the project root. If your PHP does not live on the host — Sail, Docker, or another container — substitute the command your project actually uses, such as./vendor/bin/sailwith argsartisan mcp:start laravel-openapi.
Available MCP tools
| Name | Notes |
|---|---|
readme |
Read this README, covering the #[ApiSchema] attribute, both Artisan commands, and the test trait |
api |
List the supported classes as PHP stubs: public members, plus a trait's protected ones, internals excluded |
Neither tool takes arguments. api closes with a Total public methods: count, so an agent — or a reviewer reading a
diff — can tell at a glance when a change grew or shrank the supported surface.
Manually registering the MCP server
Sometimes you may need to register the server with an editor not listed above. You should register it using the following details:
JSON example:
{
"mcpServers": {
"laravel-openapi": {
"command": "php",
"args": ["artisan", "mcp:start", "laravel-openapi"]
}
}
}
The handle is configurable. Set openapi.mcp.handle to rename it, and pass the new name to mcp:start and to every
registration above. Set openapi.mcp.enabled to false to register no server at all.
Configuration reference
| Key | Default | Purpose |
|---|---|---|
openapi.route.* |
see above | Where the schema endpoint lives |
openapi.openapi |
3.0.4 / JSON:API / 1.0.0 / [{url: '/'}] |
Document-level fields |
openapi.mcp.enabled |
true |
Whether the MCP server is registered |
openapi.mcp.handle |
laravel-openapi |
The handle mcp:start is called with |
openapi.coverage.path |
storage/framework/cache/openapi-coverage.jsonl |
Where validated responses are recorded |
Known limitations
Worth knowing before you rely on any of this.
Declared paths are not checked against the routes they annotate. An attribute on a route at /foo can declare
/bar, and openapi:validate will happily pass — the document is well-formed, just untrue. The behavior layer is
what catches it: requests to /foo resolve to no operation, and /bar is never exercised so coverage fails. If you
only run openapi:validate, this class of drift is invisible.
Changing openapi.route.uri desyncs the built-in endpoint's own documentation. SchemaController declares itself
at /openapi.json in a PHP attribute, which cannot read config. Move the route and the document still describes the old
path. This affects only this package's endpoint, and assertMatchesSchema() reports it as no such operation.
Response body validation is fail-fast. A response violating three rules reports one. You fix them one round-trip at a time. Document validation, by contrast, reports everything at once.
Coverage accumulates in static state plus the file. --reset deletes the shared file, so run it before a suite,
never between tests. assertSchemaFullyExercised() relies on in-process state and must run after the rest of the
suite; under pest --parallel, use the openapi:coverage command instead.
openapi:coverage sees the routes registered in its own process. Routes that exist only inside a test cannot be
reported as missing.
Contributing
composer install
composer test
The suite uses Pest and Testbench. TestCase::withConfig()
rebuilds the application so the provider boots against new config — necessary because route registration happens
during boot.
composer check is the gate everything has to pass, and composer fix is the only script that rewrites your files:
| Script | Runs |
|---|---|
composer check |
lint, rector-lint, analyse, coverage, bc-check — mutates nothing |
composer fix |
rector, format — rewrites files in place |
composer coverage |
Pest with --min=100; the suite is expected to stay at full coverage |
composer require-check |
ComposerRequireChecker against a require-dev-stripped tree |
composer bc-check |
Roave BC check against the last SemVer tag; skips when the repo has no tags |
bc-check and require-check install their tooling into build/ on first run, so both need network access once.
The MCP server can be driven from the command line without attaching an agent, which is the quickest way to see what a tool actually returns after you change one:
composer mcp list # tool names and descriptions
composer mcp call readme # a tool's output
composer mcp call api
It speaks to vendor/bin/testbench mcp:start, so it exercises the same registration path a consumer's app uses. Set
MCP_HANDLE if you have also changed openapi.mcp.handle; the two have to agree or the server is not registered
under the name the script asks for.
License
MIT