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": "Substring match on `number`.", "required": false, "schema": { "type": "string" } }, { "name": "filter[status]", "in": "query", "description": "Exact match on `status`. 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": "sort", "in": "query", "description": "Sort by: issued_at, total (prefix `-` for descending).", "required": false, "style": "form", "explode": false, "schema": { "type": "array", "default": ["-issued_at"], "items": { "type": "string", "enum": ["issued_at", "-issued_at", "total", "-total"], "x-enum-varnames": ["IssuedAt", "IssuedAtDesc", "Total", "TotalDesc"] } } } ], "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 |
An enum of the allowedSorts() names, ascending and descending both, with defaultSort() as the schema default. |
page |
The paginate(20) call. The 20 is fixed at that call site, so no page-size key is documented beside it — nothing reads one. |
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.
How it works is the mechanism behind that table: what Docuccino reads at each stage of the pipeline, and which source wins when two of them describe the same field.
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, which is where the viewer is configured: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. --format picks another; --yaml is a serialization
rather than a format, so it applies to whichever of them you asked for:
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=openapi-3.0 --yaml --out=docs/openapi-3.0.yamlAvailable on the three plain OpenAPI formats and on arazzo. full and postman are parsed
by tools that read JSON and nothing else, so a .yaml path on either is refused rather than filled
with JSON.
php artisan docuccino:export --format=full --out=docs/api.full.jsonphp artisan docuccino:export --format=postman --out=docs/collection.jsonphp artisan docuccino:export --format=arazzo --out=docs/workflows.arazzo.yamlThe full document is what Docuccino builds before emitting: the OpenAPI it would export, plus the
Docuccino extension carrying a stable identity for every operation and schema
and the provenance of each detail. It’s what docuccino:diff compares, so commit it when you want
the most precise diffs.
The Arazzo file describes the workflows a document declares — the sequences of calls a consumer follows to get something done. It’s written only when the document carries at least one, because Arazzo has no empty form.
Several artifacts at once
Section titled “Several artifacts at once”If you ship more than one of these, configure them as targets in docuccino.yaml rather than running
the command once per format — analysis is the expensive half, and targets share one:
documents: default: export: targets: - { format: 'openapi-3.2', path: 'docs/openapi.json' } - { format: 'openapi-3.1', path: 'docs/openapi-3.1.yaml' } - { format: 'postman', path: 'docs/collection.json' }php artisan docuccino:export# Wrote /home/you/invoices-api/docs/openapi.json (openapi-3.2).# Wrote /home/you/invoices-api/docs/openapi-3.1.yaml (openapi-3.1).# Wrote /home/you/invoices-api/docs/collection.json (postman).The path’s extension picks the serialization — no --yaml needed. See
export for the full rules.
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 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.
A 3.0 export is produced through the 3.1 one, so it carries everything the 3.1 target drops as well as its own losses. The first group below is what 3.1 already gives up; the rest is 3.0’s:
| In 3.2 | In the 3.1 and 3.0 exports |
|---|---|
The query HTTP method |
Dropped — the operation is not in the emitted document |
additionalOperations |
Dropped — model custom methods with a standard method |
A tag’s summary |
Dropped — tags fall back to their name for display |
A tag’s parent |
Dropped; the hierarchy survives as x-tagGroups, which a generated document carries |
A tag’s kind |
Dropped — consumers treat every tag the same |
Any other member 3.2 added — a media type’s description, itemSchema, prefixEncoding and itemEncoding, an encoding’s nested encoding, an example’s dataValue and serializedValue, a response summary, a server name, a security scheme’s deprecated and oauth2MetadataUrl, an OAuth deviceAuthorization flow, components.mediaTypes |
Dropped where it stood, with the pointer in the diagnostic. A shared media type is inlined where a $ref named it, so no reference dangles |
An in: querystring parameter |
Dropped — 3.1 has no way to say “the raw query string as one value”, and a query parameter is a different contract. A shared one takes every $ref that named it with it |
A cookie parameter’s style: cookie |
Dropped, and nothing written in its place — form is the only cookie style 3.1 spells and its default there, and both styles default explode to true. What 3.1 cannot say is the rest of RFC 6265: form percent-encodes where cookie escapes nothing, and joins an exploded array or object on & rather than ; |
| 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"] |
A subschema of true or false |
{} or {"not": {}} — the same constraint, in the only spelling 3.0 has for it |
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 |
$anchor, $defs, $id, $schema, additionalItems, contains, contentMediaType, contentSchema, definitions, dependentRequired, dependentSchemas, else, if, maxContains, minContains, patternProperties, prefixItems, propertyNames, then, unevaluatedItems, unevaluatedProperties |
Dropped — 3.0 cannot express them |
webhooks, components.pathItems, info.summary |
Dropped — 3.0 has no such member |
A path $ref-ing a shared path item |
Inlined where it stands, since the shared one is gone |
A path whose $ref chain reaches no shared path item |
Dropped — an undefined name or a cycle leaves nothing to inline, so a 3.0 consumer loses the endpoint |
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 |
| An operation with no responses | A placeholder default response — 3.0 requires every operation to declare at least one |
Everything the two tables don’t name is identical to the 3.2 output, bar one thing no diagnostic can
tell you about: a schema’s $comment is dropped without a note, because it addresses whoever wrote the
schema and says nothing a consumer could act on. Keep the 3.2 artifact around as
the full-fidelity copy — it is the only one that loses nothing — and treat the 3.0 one as what you hand
to the pinned toolchain. The diagnostics reference
has a row per downlevel.* code, including the ones that only fire on documents using the construct.
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=warningThe value is a floor — anything that severity or louder fails the run.
--fail-on=none never fails on severity (the default),
--fail-on=error fails only on errors,
--fail-on=warning adds warnings,
--fail-on=info adds the places Docuccino documented less than your
code describes, and --fail-on=hint catches everything it reported.
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.
If your own export came out thinner than the one above — parameters but no response bodies, an
engine.not-installed warning, or nothing at all — Troubleshooting
starts from the symptom and works back to the cause.
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.