OpenAPI & documentation¶
The package projects an OpenAPI 3.1 document from your discovered resources — no
hand-written spec. It serves a Swagger UI / ReDoc viewer, exposes the document + JSON Schema
over HTTP, and exports them from the CLI. Crucially, for an identical domain the document is
byte-identical to the Symfony bundle's (PLAN decision 11), so a json-api-ts client
generator consumes either backend unchanged.
What is projected¶
Everything the resources declare becomes the document: paths (one per type × operation),
parameters (filter / sort / page / fields / include), request/response schemas,
components.schemas (per-type schemas + reusable enum components), tags, and security. A
backed-enum field (status->enum(AlbumStatus::class)) projects a reusable
#/components/schemas/AlbumStatus with described cases. Custom actions and the
atomic endpoint project too.
The error codes a server can raise are catalogued as one named schema variant each, so a generated client gets a typed exception per code rather than one opaque error type. Core's codes are always there; your own join them when discovery finds them — see errors.
Per-operation response declarations¶
By default each operation advertises one success response — POST → 201, PATCH → 200,
DELETE → 204, GET → 200. A resource overrides the set per operation with the
create/update/delete/fetchOne/fetchCollection params
on #[AsJsonApiResource], and a custom action with its responds set. This is
how the asynchronous-write lifecycle (async writes) becomes part of the
contract:
new Accepted('export-jobs')→ a202 Acceptedwhose body is theexport-jobsdocument, withContent-Location(the poll URL) andRetry-Afterheaders.new SeeOther()onfetchOne→ a303 See Otherwith aLocationheader (the completed job redirecting to the produced resource), no body.new NoContent()→ a204;new MetaResult()→ a200meta-only document.
So a codegen client sees, from the document alone, that POST /catalog-exports returns 202
pointing at an export-jobs resource, and that GET /export-jobs/{id} may 303 to the
finished export. Because the responses ride core's shared projection, they are part of the
byte-compatible output.
The HTTP routes¶
When jsonapi.openapi.enabled is true and (app.debug OR openapi.expose_in_prod), the
package serves — API-wide, not under a server prefix:
| Route | Serves |
|---|---|
GET /docs.json |
the default server's OpenAPI document |
GET /{server}/docs.json |
a named server's document (e.g. /admin/docs.json) |
GET /schemas.json |
the standalone JSON Schema documents |
GET /docs |
the viewer UI (Swagger UI by default; ReDoc optional) |
Configure the paths, the renderer, and the prod gate under jsonapi.openapi — see
configuration. Every JSON:API response can carry a top-level
links.describedby pointing at the served document (openapi.describedby), emitted only when
the document routes are actually reachable.
The CLI exporters¶
The export commands are always available (independent of the HTTP expose gate):
php artisan jsonapi:openapi:export --server=default --output=build/openapi.json
php artisan jsonapi:openapi:export --server=admin --format=yaml
php artisan jsonapi:jsonschema:export --server=default --output=build/schemas
--server selects the server (default default); --format is json (default) or yaml;
--output writes to a file/directory (omit for stdout). The document renders byte-clean
(toJsonString(true)."\n").
Profile-gated parameters (x-profile)¶
Some query parameters are recognised by the runtime only when a JSON:API
profile is negotiated on the request (its URI
supplied via the profile media-type parameter of Accept / Content-Type). withCount
is the built-in case — the count tokens are honoured only under the Countable profile.
The projected document advertises this constraint honestly: every such parameter carries an
x-profile vendor extension whose value is the profile URI that must be negotiated for
the parameter to take effect. A client (or a codegen tool) that does not negotiate that
profile should treat the parameter as inert. The withCount parameter on a countable
collection (HasMany::make('albums')->countable()) is emitted as:
{
"name": "withCount",
"in": "query",
"schema": { "type": "array", "items": { "type": "string", "enum": ["tracks"] } },
"style": "form",
"explode": false,
"x-profile": "https://haddowg.github.io/json-api/profiles/countable/"
}
The extension is produced by core's projection, so it is present on every served build
path — the warmed artifact, the document/viewer controllers, and
the CLI export alike; the package serves the projected document
verbatim. Because it rides the shared projection, it is part of the
byte-compatible output — you can grep any
exported document (e.g. build/laravel-default.json) for x-profile to see it.
The info, tags, and security blocks¶
Fill the document metadata under jsonapi.openapi:
'openapi' => [
'info' => [
'title' => 'Music Catalog API', 'version' => '1.0.0',
'description' => 'A JSON:API music catalogue.',
'contact' => ['name' => 'Catalog Team', 'email' => 'api@music.example'],
'license' => ['name' => 'MIT'],
],
'tags' => [
['name' => 'Catalog', 'description' => 'Albums and album actions.'],
['name' => 'Library', 'description' => "A user's saved playlists."],
],
'security' => [
'schemes' => ['bearer' => ['type' => 'bearer', 'bearerFormat' => 'JWT']],
'default_requirement' => [['name' => 'bearer']],
],
],
A resource groups its operations under tags: (a type referencing an undefined tag falls back
to a humanized default). Security is declared, not derived from runtime enforcement, so the
projection is stable: the default_requirement is the document-level requirement, and a
secured operation (via abilities, a relation security, or an action
ability) inherits it (a 401). A read declared abilities: ['read' => false] projects
security: [] (public).
The servers list¶
Unless jsonapi.openapi.servers overrides it, each document's servers comes from the
JSON:API server it describes:
- A configured
base_uri(global or the server's own) is the one server URL, as it is (https://api.example.com/api). - An empty
base_uriadvertises the prefix the server's routes are registered under, as a relative URL, so a client resolves/albumsagainst the document's host as/api/albums. Routes registered with no prefix advertise noservers. A server whose routes sit under two prefixes (say, auto-registered underapiand placed again withRoute::jsonApi()underv2) gets one entry per prefix, sorted, and an unprefixed placement beside them adds/. - A prefix with route parameters (
{tenant}/api) becomes a templated URL with a server variable whosedefaultis the route default for that parameter, set with the server'sroute_defaultskey:
'servers' => [
'default' => ['prefix' => '{tenant}/api', 'middleware' => [], 'domain' => null, 'route_defaults' => ['tenant' => 'acme']],
],
OpenAPI requires a variable default, so a parameterised prefix without a route default is
left out of servers rather than given an invented value.
The list is read from the registered routes, not from a request, so it is the same whether
optimize or the first request builds the document. That also means it can't see an app
served below the web root (/app/index.php) or a proxy prefix: those are only known per
request. Set jsonapi.openapi.servers (or a configured base_uri) when the API is reached
through one. In combined mode the document lists every server's entries, deduplicated by
URL.
Multi-server¶
openapi.multi_server chooses separate (one document per server, per-server routes — the
default) or combined (a single union document, requires non-colliding types across servers).
The example uses separate and exports both the default and admin documents.
Byte-compatibility with the Symfony bundle¶
Because both integrations implement core's Metadata/* contract, the projected document for an
identical domain is byte-identical (bar the info block and advertised server URLs, which are
platform-legitimate). The music-catalog workbench exists to prove it: composer byte-compat
exports both twins' default + admin documents, normalizes info/servers[].url, and diffs
them — the diff must be empty. The CI job runs it against the sibling bundle checkout. See
workbench.
Warming for production¶
The optimize pipeline warms the OpenAPI artifact (and the discovery snapshot)
so production serves a pre-built document with no scan. jsonapi.openapi.public_path can also
write static .json/.yaml for a CDN.