Skip to content

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.

Terminal window
php artisan docuccino:export
Wrote /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.

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:

app/Http/Controllers/InvoiceController.php
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);
}
}

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, InvoiceControllerInvoice.
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.

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:

  1. Set the ability in config/docuccino.php:

    documents.default.viewer.gate
    'gate' => 'viewApiDocs',
  2. 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.

docuccino:export emits OpenAPI 3.2 by default, and four other artifacts on request:

Terminal window
php artisan docuccino:export --out=public/openapi.json

The 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.

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.

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:

Terminal window
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.

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.