Your first export
With Docuccino installed, you can document an existing API without writing a single annotation. This page runs the export, shows exactly what came out, and points you at the viewer and CI.
Run the export
Section titled “Run the export”php artisan docuccino:exportWrote /home/you/invoices-api/docs/openapi.json (openapi-3.2).With no arguments this builds every configured document and writes each to its own export.path.
The published default writes one document to docs/openapi.json as OpenAPI 3.2, creating the
directory if it doesn’t exist.
What it read
Section titled “What it read”Docuccino walks each route that matches your document’s filters and assembles an operation from your code. Here’s a list endpoint you’d write anyway, and the operation it produces:
final class InvoiceController{ /** * List invoices, filterable and sortable via the query string. */ public function index(): AnonymousResourceCollection { $invoices = QueryBuilder::for(Invoice::class) ->allowedFilters(['number', AllowedFilter::exact('status')]) ->allowedSorts(['issued_at', 'total']) ->defaultSort('-issued_at') ->paginate(20);
return InvoiceResource::collection($invoices); }}"/api/invoices": { "get": { "operationId": "invoices.index", "summary": "List invoices, filterable and sortable via the query string.", "tags": ["Invoice"], "parameters": [ { "name": "filter[number]", "in": "query", "description": "Partial-match filter", "required": false, "schema": { "type": "string" } }, { "name": "filter[status]", "in": "query", "description": "Exact-match filter. Accepts a comma-separated list of values (matched as `whereIn`).", "required": false, "style": "form", "explode": false, "schema": { "type": "array", "items": { "$ref": "#/components/schemas/InvoiceStatus" } } }, { "name": "page", "in": "query", "description": "Page number.", "required": false, "schema": { "type": "integer", "default": 1, "minimum": 1 } }, { "name": "per_page", "in": "query", "description": "Items per page.", "required": false, "schema": { "type": "integer", "default": 20, "minimum": 1 } }, { "name": "sort", "in": "query", "description": "Sort by: issued_at, total (prefix `-` for descending).", "required": false, "schema": { "type": "string", "default": "-issued_at" } } ], "responses": { "200": { "description": "OK", "content": { "application/json": { "schema": { "type": "object", "properties": { "data": { "type": "array", "items": { "$ref": "#/components/schemas/InvoiceResource" } }, "links": { "type": "object" }, "meta": { "type": "object" } }, "required": ["data", "links", "meta"] } } } }, "400": { "description": "Bad Request", "content": { "application/json": { "schema": { "type": "object", "properties": { "message": { "type": "string" } } } } } } } }}Nothing in that controller is written for the docs. Every line of the output is derived:
| Output | Where it came from |
|---|---|
operationId |
The route’s name, invoices.index. |
summary |
The action’s docblock summary. |
tags |
The controller name, InvoiceController → Invoice. |
filter[number], filter[status] |
The allowedFilters() allow-list, traced through the builder. status is an array of the InvoiceStatus enum because the model casts that column to it — and because Query Builder matches a comma-separated list as whereIn. |
sort |
The allowedSorts() names, with defaultSort() as the schema default and the - descending convention in the description. |
page, per_page |
The paginate(20) call — hence the per_page default of 20. |
200 |
InvoiceResource::collection() over a paginated result: an array of the shared InvoiceResource schema inside Laravel’s length-aware { data, links, meta } envelope (trimmed above). |
400 |
Query Builder’s strict mode, which rejects an unknown filter or sort with InvalidQuery. |
Add a StoreInvoiceRequest to a store() action and its rules() become the request body. Throw
InvoiceNotFoundException and a 404 appears with the shape your handler really returns. That’s the
whole loop — write ordinary Laravel, get documentation.
Open the viewer
Section titled “Open the viewer”The built-in viewer serves an interactive Scalar reference straight from your
app. With the default viewer.route of /docs/api, three routes are registered per document:
| Route | Serves |
|---|---|
GET /docs/api |
The interactive API reference page. |
GET /docs/api.json |
The generated OpenAPI document. |
GET /docs/api/assets/scalar.js |
The viewer script, served from your app — no external CDN. |
The viewer answers in your local environment and nowhere else. To open it up elsewhere, name a
gate ability and define it:
-
Set the ability in
config/docuccino.php:documents.default.viewer.gate 'gate' => 'viewApiDocs', -
Define it in a service provider:
Gate::define('viewApiDocs', fn ($user) => $user?->isAdmin() ?? false);
Once gate is set, it is the only check — the local-environment shortcut no longer applies, so the
same rule holds in every environment.
Other formats and paths
Section titled “Other formats and paths”docuccino:export emits OpenAPI 3.2 by default, and four other artifacts on request:
php artisan docuccino:export --out=public/openapi.jsonphp artisan docuccino:export --format=openapi-3.1 --out=public/openapi.jsonphp artisan docuccino:export --format=openapi-3.0 --out=public/openapi.jsonphp artisan docuccino:export --yaml --out=docs/openapi.yamlphp artisan docuccino:export --format=uir --out=docs/api.uir.jsonThe UIR is the document Docuccino builds before emitting: OpenAPI-shaped, but carrying a
stable identity for every operation and schema plus the provenance of each detail. It’s what
docuccino:diff compares, so commit the UIR when you want the most precise diffs.
OpenAPI 3.0 export
Section titled “OpenAPI 3.0 export”Reach for --format=openapi-3.0 when something downstream is pinned to 3.0 — AWS API Gateway,
an older code generator, a validator that predates 3.1. It writes openapi: 3.0.4.
3.0 is a smaller spec than the one Docuccino builds against, so a few things change shape on the way
down. Each one prints a downlevel.* diagnostic naming the JSON pointer it happened at, so you can
see exactly what a 3.0 consumer will and won’t get:
| In 3.1 / 3.2 | In the 3.0 export |
|---|---|
type: [string, null] |
type: string with nullable: true |
anyOf: [{$ref}, {type: null}] |
allOf: [{$ref}] with nullable: true |
type: [string, integer] |
An anyOf of single-type branches |
const: "draft" |
enum: ["draft"] |
Schema examples: [a, b] |
example: a |
exclusiveMinimum: 0 |
minimum: 0 with exclusiveMinimum: true |
contentEncoding: base64 |
format: byte |
A description beside a $ref |
Hoisted, with the $ref moved into an allOf |
if / then / else, prefixItems, patternProperties, contentMediaType, unevaluatedProperties |
Dropped — 3.0 cannot express them |
webhooks, components.pathItems, info.summary |
Dropped — 3.0 has no such member |
An SPDX info.license.identifier |
info.license.url, pointing at the SPDX page |
A mutualTLS security scheme |
Dropped, along with every requirement that named it |
Everything else is identical to the 3.2 output, so keep the 3.2 or 3.1 artifact around as the full-fidelity copy and treat the 3.0 one as what you hand to the pinned toolchain.
See the commands reference for every flag.
Gate CI on problems
Section titled “Gate CI on problems”Docuccino reports diagnostics — a controller it couldn’t read, a query it couldn’t resolve, a property name that looks like a secret — grouped by route, in a deterministic order. Turn them into a failing build:
php artisan docuccino:export --fail-on=warning--fail-on=none never fails on severity (the default),
--fail-on=warning fails on warnings and errors, and
--fail-on=error fails only on errors. Whichever you pick, a route
whose analysis fails still leaves a stub operation and a diagnostic behind rather than aborting the
run, so one bad controller never costs you the rest of your docs. Set on_route_error to 'omit' if
you’d rather such a route vanished from the document than appeared as a stub.
Where to go next
Section titled “Where to go next”You’ve seen the base export. Documenting your API is the reference for everything Docuccino reads — requests, responses, schemas, errors, authentication, rate limiting — and where to reach for an attribute when your code can’t say it all.