Configuration reference
Docuccino reads two files, and they do not overlap.
docuccino.yaml, at the root of your project, holds everything that shapes a document:
documents, extensions, lint, diagnostics, engine, on_route_error and cache.enabled. A
command reads it once per build, so nothing your application serves ever parses it.
config/docuccino.php holds only what Laravel reads while it boots or on a viewer
request: enabled, each document’s viewer block, and cache.store. The viewer’s routes are
registered on every boot, and a boot that had to parse a project file to decide whether a route exists
would fail on a file somebody is halfway through editing.
php artisan docuccino:install writes both. Nothing left in config/docuccino.php is merged over
docuccino.yaml — a build setting still sitting there is reported (config.stale-php-keys) and
ignored, and an application whose build settings are all still there, with no docuccino.yaml at all,
is refused rather than built from defaults (config.not-migrated). The refusal is the whole of it: no
command that builds a document runs in that state, whatever --fail-on says, until docuccino:install
has written the file. The same goes for a docuccino.yaml that is there and cannot be read — not
valid YAML, not a map of settings, or unreadable on disk. A document built from defaults in any of
those states would look plausible and have nothing to do with the file you edited, so nothing builds
one.
Neither file is required. With no configuration at all you get one document, called default,
configured exactly as the shipped docuccino.yaml describes it: the routes under api/*, titled
“API Documentation” at version 1.0.0, the framework’s own error shapes documented, and exported to
docs/openapi.json. So docuccino:install does not change the document you already had — it writes
those settings somewhere you can edit them.
Every key is listed in the shipped files themselves — required keys active, optional ones commented out — so you can discover the whole surface by scrolling through them. This page is the long-form version: what each key does, what it defaults to, and where its behavior is explained in full.
A key in docuccino.yaml that isn’t one of them is reported (config.unknown-setting) rather than
passed over, and the message names the one it was probably meant to be. That matters most for
indentation: a block one level too far in doesn’t misspell a key, it moves the whole bag under the
wrong parent, and everything in it goes quiet together.
A value is read as the type its setting takes, and refused rather than converted where it is not: a
setting that takes text and holds a number is reported (config.value-type), a switch that holds
anything but true or false is reported (config.not-a-switch), and a setting whose Values
column lists a closed set — error_responses, tags.default_strategy, the representation policy
keywords, versioning, on_route_error, viewer.source — holding none of them is reported
(config.unknown-value). In every case the setting falls back to its documented default and the
message names the setting, what you wrote and the value used instead. YAML is why this is worth
saying: nullable: no is the text no, 1.10 is the number 1.1, and a bare date is a number too,
so a value converted quietly would be a policy nobody chose.
The Default column is the value the published file ships with. For most keys that is also the
built-in fallback you get by deleting the key, but not for all of them, and the three that differ all
read an omitted key as “no opinion”: error_responses falls back to 'none', routes.include to no
route filter at all, and security.auth_middleware to treating no route as authenticated. That is why
a second document inherits nothing from the first — and why a
document nobody wrote at all gets the shipped values above rather than those fallbacks.
Build configuration
Section titled “Build configuration”docuccino.yaml, at the root of your project. Every key in it shapes the emitted document, and a
command is the only thing that reads it.
documents: {} # one entry per document; the rest of this half of the page is what goes in oneextensions: []lint: {}diagnostics: {}engine: {}on_route_error: 'skeleton'cache: {}| Key | Default | Effect |
|---|---|---|
on_route_error |
'skeleton' |
Per-route failure behavior. skeleton emits a stub operation plus an error diagnostic (never a dead build); omit drops the route entirely. |
A key written here, even as an empty value, is part of the configuration a document is fingerprinted
from — so an option you are not using stays commented out rather than sitting there as null.
Documents
Section titled “Documents”documents is a map of independent pipeline runs. Each entry has its own route filters, info,
servers, security, content, and export target, and shares route contexts + the TypeEngine
in-process. The published config ships one document, default.
api_version
Section titled “api_version”# api_version:# changes: ['app/Api/Versions']# header: 'X-Api-Version'| Key | Values / default | Effect |
|---|---|---|
changes |
list of directories / [] |
Where the #[ApiVersionChange] classes live. Each entry may be a glob, so a modular application writes 'modules/*/Api/Versions' once instead of listing its modules — and an entry matching a path outside the application is refused, wildcard or not. Every directory is read, and the changes are ordered by version then class name however they were spelled, so which entry a class came out of never decides when it applies. |
header |
header name / X-Api-Version |
The request header the version enum is published on. |
Declaring api_version makes the document an API version rather than a document that merely has
one. Three things follow.
The version is info.version — the one OAS already models. There is no second key naming it,
because a second key could only ever disagree with the first. Write no info.version at all and there
is nothing to derive a version from; the build says so with a versioning.version-unstated warning
rather than inventing a version you do not serve. Any value you do write is taken at its word, 1.0.0
included — declaring api_version is the statement that this document is a version.
The order two versions are in is the one versioning names — date or semver. Say
nothing and Docuccino reads the order off the versions themselves, so an application writing plain
dates or plain semver never has to write it down twice. Versions that are neither, or a mixture, cannot
be ordered and the changes are not applied (versioning.unordered).
Every operation gains the header a client pins a version with — in: header, optional, defaulting to
this document’s version and enumerating every version your documents map declares. Consumers read
the set of supported versions out of the document itself, and a generated client gets a named constant
for each one.
That enum is every configured version, not every public one, and a published document is a committed
artifact — so an internal, staging or not-yet-announced version configured as a document is named in
the public one. It has to be: an enum narrower than what the server accepts marks a working request
invalid. Keep a version out of the enum by keeping it out of the documents map.
Every change declared under changes.dir that shipped after this version is applied in reverse, so
an older version’s document describes the shape that version published. Your code is always the newest
version; a change class says what the API did before it:
use Docuccino\Attributes\Versioning\ApiVersionChange;use Docuccino\Attributes\Versioning\RenamedResponseField;
#[ApiVersionChange(since: '2026-09-01', description: 'An invoice publishes `total` where it published `amount`.')]#[RenamedResponseField(schema: InvoiceResource::class, from: 'amount', to: 'total')]final class InvoiceTotalReplacesAmount {}Nothing in that class is executed, and its body is never read — Docuccino compiles the declaration and stops there. Serving an older shape at runtime is your application’s job, and a per-version contract test is what holds the two halves together: replay your suite with a version pinned, and every response must validate against that version’s document.
Migrating stored records to a new shape is a different problem, and API versioning does not solve it.
The whole loop — declare a change, get a document per version, hold both to your test suite — is in the API versioning guide.
info: title: 'API Documentation' version: '1.0.0' # description: { file: 'resources/docs/api/description.md' }Maps to OAS info, and any other OAS info field you add (contact, license,
termsOfService, …) is emitted as written. description may be a Markdown string or
['file' => '…md'] to load your API’s introduction from a file. version is the value the
versioning policy evaluates during docuccino:diff --enforce.
servers
Section titled “servers”servers: - { url: 'https://api.example.com' } # Server variables with defaults and descriptions: # - url: 'https://{tenant}.example.com' # variables: # tenant: { default: 'acme', description: 'Tenant slug' } # A variable whose legal values are a closed set: # - url: 'https://api.example.com/{version}' # variables: # version: { default: 'v2', enum: ['v1', 'v2'] }Emitted as OAS servers, including server variables.
Leave it empty and Docuccino fills it in from app.url. An application configured with
APP_URL=https://api.acme.com publishes that as its one server without you writing a line — the
common case, handled. The test is whether a reader of the document could reach the host, and the
reader of a document you export is outside your network — so localhost, 127.0.0.1 and anything
under .test or .local are dropped, and so are a private or link-local address (192.168.1.50,
10.0.0.5, 169.254.169.254), a container alias like host.docker.internal, and a name with no
dots such as a CI runner’s hostname. A value that is not a full http/https URL is dropped too.
The document then publishes no servers at all, which OpenAPI reads as the origin the document is
served from: a generated client and a viewer both keep working, and neither is sent at a host that
answers only inside your network. Write the key yourself whenever the URL clients use is not the one
the application runs on.
Every OpenAPI version requires a default on a variable, so give each one a value you serve. A
variable without one raises server.variable-no-default
against every format the build emits, and what happens next depends on whether the variable declares an
enum. With one — as version does above — its first value stands in, which resolves the URL to
something you demonstrably serve. With neither, an OpenAPI document leaves the variable out rather than
invent a value, and a Postman collection publishes it blank for a person to fill in.
An empty string is not a default. 'default' => '' reads as no default and takes the path above,
because substituting it resolves https://{tenant}.example.com to https://.example.com — a URL nobody
serves, which leaves a reader exactly where declaring nothing does.
A dropped variable leaves its placeholder behind. Only the variables entry is removed; the url is
published as written, so a {tenant} with no default and no enum stays literally {tenant} in the
emitted URL. That is deliberate — a template a consumer must fill in is honest, and a guessed hostname is
not — but it means the warning is worth acting on rather than accepting.
For a worked multitenant subdomain example ({tenant}.example.com), see
Deploying to production.
routes
Section titled “routes”routes: include: ['api/*'] exclude: [] # filter: App\Docs\PublicRoutes include_vendor: falseRoute selection. include/exclude are URI globs; filter names a class for the logic globs can’t
express. A route must pass includes, fail excludes, and be admitted by the filter to be documented.
filter is the name of a class implementing Docuccino\Core\Extensions\Contracts\RouteFilter:
namespace App\Docs;
use App\Support\TenantRegistry;use Docuccino\Core\Extensions\Context\RouteDescriptor;use Docuccino\Core\Extensions\Contracts\RouteFilter;
class PublicRoutes implements RouteFilter{ public function __construct(private TenantRegistry $tenants) {}
public function includes(RouteDescriptor $route): bool { return $route->domain === null || $this->tenants->isPublic($route->domain); }}It’s built by the container, so it takes its dependencies in the constructor, and it’s handed a
RouteDescriptor — methods, URI, name, action, middleware and
domain. Returning false omits the route entirely: no operation, and no diagnostic.
A filter that can’t be built stops the run, rather than documenting every route the globs
admitted. A class that isn’t autoloadable, doesn’t implement the contract, or throws while the
container builds it is reported as a
config.route-filter-unusable error and nothing is written — the
route set you’d get by skipping the filter is a superset you explicitly narrowed, so publishing it
would describe a surface you’d said wasn’t yours.
Routes whose resolved controller class file lives under the application’s vendor/ directory are
excluded by default — the same as php artisan route:list --except-vendor — so an installed
package’s own routes don’t leak into your API reference. Closures and your own app controllers are
never affected, and the include/exclude/filter filters are unchanged. Set include_vendor to
true to document installed packages’ routes.
security
Section titled “security”security: auth_middleware: 'auth*' # schemes: # bearer: { type: 'http', scheme: 'bearer', bearerFormat: 'JWT' } # apiKey: { type: 'apiKey', in: 'header', name: 'X-API-Key' } # oauth2: { type: 'oauth2', flows: {} } # oidc: { type: 'openIdConnect', openIdConnectUrl: 'https://id.example.com' } # default: [{ bearer: [] }] # per-op requirement for auth-detected routes # document: [{ bearer: [] }] # document-wide security requirement| Key | Default | Effect |
|---|---|---|
auth_middleware |
'auth*' |
Wildcard (* stands for any run of characters; everything else, \ included, is literal) matched against each route’s middleware, in either spelling — the registered alias or the middleware’s own class name; a match applies the default requirement. |
schemes |
none | components.securitySchemes — full breadth: http bearer/basic, apiKey (header/query/cookie), oauth2 flow builders, OpenID Connect. |
default |
none | The per-operation security requirement applied to auth-detected routes. |
document |
none | A document-wide security requirement. |
Declaring any schemes here defers the auto-config security integrations (Sanctum, Passport)
— explicit config wins. #[Unauthenticated] clears a route’s requirement regardless.
error_responses
Section titled “error_responses”error_responses: 'default' # 'default' | 'none'Selects what is published for the exceptions your application does not render itself. default
documents Laravel’s stock JSON error shapes; none emits no error responses. Either way, an
inferred exception handler that recovers your app’s real error shape —
its body, its status and the media type it is sent as — wins ahead of this fallback. The strategy also
governs the implicit 401/422/404/403 responses
and the throttled 429 — none of them are emitted under none.
To drop some and keep others, leave this at default and name the ones you don’t want with
#[IgnoreResponse].
Deleting the key is how you ask for none — that is the fallback a document that doesn’t name the key
gets, and it is why a second document inherits nothing from the
first. Keeping the key and giving it anything else — a misspelling, or a key with nothing after the
colon, which reads as null — is reported as config.unknown-value and read as default, so a value
that can’t be read never quietly empties the document of its errors.
tags: default_strategy: 'controller' # 'controller' | 'none' map: {} # mapper: 'App\Docs\InvoiceTagMapper' # container-resolved TagMapper, replacing the prefix mapper # definitions: # OAS top-level `tags`, sorted by weight then name # - { name: 'Billing', summary: 'Billing', kind: 'nav', weight: 0 } # - { name: 'Forms', description: 'Form endpoints.', parent: 'Billing' }default_strategy tags an operation that has no #[Group]: controller (the default — the
controller’s short name with a trailing Controller stripped, e.g. FormController → Form, then
run through map) or none (leave it untagged). Anything else is read as controller and reported
(config.unknown-value). Closure routes are never auto-tagged.
map is a raw-tag → display-tag table (exact match wins, else the first matching prefix).
mapper swaps in a custom TagMapper. definitions supplies OAS top-level tag objects.
mapper names a class because a YAML file cannot hold a callable, so the name is resolved out of
the container and your mapper can take its own constructor dependencies. Setting it means it
decides: map is not consulted. A name no mapper can be got from — a typo, a class that doesn’t
implement the contract, one the container can’t build — is reported as
config.tag-mapper-unusable and the build carries on with your tags unmapped, because the names
your controllers and #[Group] attributes wrote are still your API’s real tags.
A definition carries the full OAS 3.2 Tag Object: name (required), plus optional summary,
description, parent and kind. weight is Docuccino’s own — it orders the emitted array
(ascending weight, then name) and is never emitted.
| Field | Purpose |
|---|---|
summary |
A short display label, where description is the prose. |
parent |
The name of the tag this one nests under, for a grouped sidebar. |
kind |
A machine-readable category — nav, badge, audience are the common ones; any string is legal. |
parent must name another definition, and the links must not form a cycle. A parent naming an
undefined tag emits a config.unknown-tag-parent info diagnostic; a link that closes a cycle emits
config.tag-parent-cycle. Either way the offending link alone is dropped and the build carries on,
so the emitted hierarchy is always a tree. Because the array is sorted before the parents are
resolved, the result never depends on the order you wrote the definitions in.
Wherever a hierarchy is emitted, the document also carries it as x-tagGroups at the root: a list of
{name, tags} groups, one per root tag, each listing that root and its descendants in hierarchy
order. parent is the OpenAPI-native statement of the same forest; x-tagGroups is the de-facto
convention most renderers read a grouped sidebar from, and neither has to be understood for the other
to work. Flat tags emit no x-tagGroups at all.
summary, parent and kind are OpenAPI 3.2 only. Exporting 3.1 drops them, each with its own
downlevel.tag-* warning — the tags themselves stay. A parent dropped from a document that carries
x-tagGroups costs only the native member; where nothing carries the groups, the hierarchy really is
flattened and nesting the names (“Billing / Invoices”) is the way back.
webhooks
Section titled “webhooks”# webhooks: { dir: 'app/Webhooks' }Points at a directory of classes carrying #[Webhook].
Every annotated class under it is published under the document’s webhooks — an operation your API
promises to CALL, rather than one it answers. Absent, the document has none. See
Documenting webhooks.
content
Section titled “content”content: dir: null # e.g. 'resources/docs/api'Points at a markdown tree compiled into x-docuccino.content (pages + a compiled nav tree).
Folders become default nav groups; frontmatter (title/slug/summary/tags +
nav.{group,order,hidden,type,ref}) overrides. ::operation{...} / ::schema{...} directives are
resolved against the document; broken refs become diagnostics. null compiles nothing. See
Adding your own pages for the full workflow, or the
content layer for how it lives in the full document.
examples
Section titled “examples”# examples: { recordings: 'docs/recordings' } # absent by defaultPoints at a directory of response recordings your test suite wrote, one committed file per operation,
named after the operation’s stable id. The build reads those files and publishes each body beside
the schema documented for that status and media type — no test runs, no route is dispatched, no
database is opened. Recording is opt-in per assertion: a test asks for its response to be published by
naming the scenario it set up (assertValidResponse(recordAs: 'empty-cart')), and the names publish
together as an examples map, so several scenarios can appear at once. An assertion that names nothing
checks the response and records none of it.
A recorded example sits at the integration(20) rung of the precedence ladder — above inference, below
anything you wrote, so an #[Example] or an @example
always wins — though a named recording may join a map of named examples you wrote, since the name is
one you chose too. A body whose status or media type the document doesn’t describe is never published,
and neither is one that still looks like it holds a credential.
Absent — the default — publishes no recorded examples. See Examples your tests recorded for how recordings are written and reviewed.
coverage
Section titled “coverage”# coverage: { log: 'storage/docuccino/coverage' } # the default when absentWhere the contract-coverage recorder writes what your test suite exercised, and where
docuccino:coverage reads it back. Each process
writing — a single test run, one of twenty parallel workers, one of four shards — appends the documented
responses it exercised to a file of its own; the command unions them after the run and gates on the
percentage.
Nothing in the build reads this. It is a config key rather than an argument at the call site because the suite writing and the command gating have to arrive at the same directory. Point it somewhere your repository ignores: the logs are per-run build output, not something to commit. See Contract testing.
overlays
Section titled “overlays”overlays: [] # e.g. ['resources/docs/overlays/*.yaml']Globs of OpenAPI Overlay 1.0 documents applied at
assembly time as the overlay(45) precedence layer — a standards-based hand-edit layer that
survives regeneration. See Customizing the output for worked
examples.
workflows
Section titled “workflows”Prose and inputs for the workflows your #[WorkflowStep]
attributes declare, keyed by workflow id.
# workflows:# checkout:# summary: 'Take payment for a basket'# description: 'Reserve the basket, take payment, then confirm.'# inputs: {}| Key | Default | What it does |
|---|---|---|
<workflow id>.summary |
none | One line saying what the workflow achieves, for the consumer following it. |
<workflow id>.description |
none | The longer form, CommonMark. |
<workflow id>.inputs |
none | A JSON Schema describing the values the workflow is started with, published as the Arazzo workflow’s inputs. |
Enrichment only. Config never creates a workflow, the way tags.definitions never creates a tag —
the attributes declare it and a document with no configuration publishes it fine. An entry naming a
workflow no operation declares a step of is reported with workflow.describes-nothing rather than
published, so a renamed workflow doesn’t leave prose behind pointing at nothing.
representation
Section titled “representation”representation: filters: 'bracketed' # bracketed | deepObject (Query Builder filter/field style) nullable: 'type-array' # type-array (type: [x, null]) | anyof ({type: null} branch) operation_id: 'route-name' # route-name | controller-method ({ShortController}@{method}); # an unnamed route or a closure is named from its method and path # enums: # naming: 'names' # names (both hint spellings) | none | x-enumNames | x-enum-varnames # components: true # true hoists each enum to a $ref'd component | false inlines it everywhere # errors: # components: true # true hoists a repeated error body to shared components | false inlines it # pagination: # components: true # true hoists a paginated envelope to one component per item type and # # paginator kind | false inlines it on every operation # examples: # formats: { email: 'jane@example.com' } # format => the sample examples illustrate it withSeparates what was inferred from how it is expressed in the spec. The semantic facts stay
stable in x-docuccino regardless of policy, so the diff engine can tell “representation changed”
from “API changed”.
| Key | Values | Default | Effect |
|---|---|---|---|
filters |
bracketed | deepObject |
bracketed |
Query Builder filter/field style: one flat filter[status] / fields[type] parameter each (bracketed), or a single filter / fields object parameter with style: deepObject (deepObject). It is the wire form the whole surface takes, so everything that describes one of those keys follows it — a #[QueryParameter('filter[status]')] and a 'filter.status' validation rule alike land on the flat parameter under bracketed and on the object’s property under deepObject, never on both. See Spatie Query Builder. |
nullable |
type-array | anyof |
type-array |
How nullability is expressed: type: ["string","null"] vs a {type: null} anyOf branch (legacy tooling). |
operation_id |
route-name | controller-method |
route-name |
Where the operationId comes from. route-name uses the route’s name, controller-method builds {ShortController}@{method}. Neither source is always there — an unnamed route is the ordinary case, and a closure route has no controller — and an operation with no operationId is one a client generator names a method for out of the path, differently per generator. So whichever strategy is set, an operation the strategy cannot name is named from its own method and path, spelled so the path can be read back off the name: GET /api/forms becomes get.api.forms, GET /api/forms/{form} becomes get.api.forms.@form. That spelling is what keeps one name to one operation — a reduction that folded -, _ and / together would name /api/user-profile and /api/user/profile alike. Nothing outside the operation is read, so a name never moves because another route was added, removed or renamed. Either way #[OperationId] still wins. |
enums.naming |
names | none | x-enumNames | x-enum-varnames |
names |
SDK member-name hints on enum schemas. The default names emits both spellings (x-enum-varnames for OpenAPI Generator and the TypeScript toolchain, x-enumNames for NSwag); a single-key keyword pins one tool’s shape; none turns hints off. Read by the Enum integration and the Query Builder sort/include enums. |
enums.components |
true | false |
true |
Whether each reflectable enum hoists to a shared #/components/schemas entry that properties and query-parameter item schemas $ref (true), or its type/enum/x-enumDescriptions are inlined at every use site (false). |
errors.components |
true | false |
true |
Whether a repeated error body hoists to shared components — its shape into #/components/schemas, and the whole response into #/components/responses where operations state it identically (true) — or every copy is inlined (false). |
examples.formats |
a format → sample map |
[] |
The value a synthesized example illustrates a JSON Schema format with, merged over the built-in table: a format you leave out keeps its documentation-reserved default (RFC 2606 / 5737 / 3849), and a format the built-ins don’t know can be added. A configured sample is held to the same rule as a derived one — it is validated against the finished keywords of every field carrying that format, and one that a field’s rules reject falls back to the built-in sample for that field with a config.format-sample-rejected warning naming the format, the value and the keyword. It answers for every value Docuccino invents, so a Postman collection’s fabricated bodies, saved examples, URL variables and the fill an error example puts where a member went unread all read the same table the schemas do. Configuring a format no schema uses is not an error; examples are demand-driven. It changes the document’s configHash, so it retires warm fragments. |
pagination.components |
true | false |
true |
Whether a paginated envelope hoists to one #/components/schemas entry per item type and paginator kind — ArticleResourcePage, ArticleResourceCursorPage — that every paginated operation $refs, and its links/meta to one entry per shape (PaginationLinks, PaginationMeta) that the pages $ref in turn (true); or the whole envelope is restated on each operation (false). Hoisting means an SDK generator mints one page type per item type instead of one per endpoint, over one set of envelope members instead of one per page. An envelope whose item type could not be identified, or whose item schema is not itself a component, keeps the envelope on the operation either way — but still points at the member components, whose shapes never depended on the item type. |
The hoist is narrow — 4xx/5xx only, only bodies that repeat, only responses with content, never one
already a $ref. docuccino:diff reads a reference as
the thing it names on both sides, a schema pointer included, so turning this key on is not itself a
change: neither the response moving into components.responses nor the body shape moving into
components.schemas is reported at the operations it left. A hoist that also edits the shape still
is, because the component is compared against what the inline copy said. Worked output and the exact
rules: repeated bodies become shared
components.
integrations
Section titled “integrations”One bag per integration, keyed by the integration’s config name; each integration reads only its own bag, and all are optional.
integrations: api_resources: { wrap: true } # top-level resource `data` wrapping sanctum: { modes: ['token', 'stateful'], cookie: 'myapp_session' } passport: { url: 'https://auth.example.com' } # oauth2 flow base URL query_builder: pagination_terminals: ['paginateList'] # extra paginating method names filter_descriptions: { exact: 'Matches `%field%` exactly.' } # per-kind prose permission: { enabled: true } # opt in — off by defaultEvery bag also accepts enabled (bool). It is resolved per document: an integration
contributes only when its package is installed and the document enables it. Every integration
defaults on when its package is installed, except permission, which defaults off —
documenting role and permission names would publish your application’s internal authorization
taxonomy, so it is explicit opt-in. When a package is installed but its integration is disabled, the
build emits one integration.disabled info diagnostic per document, so the switch is discoverable.
Every toggleable bag is keyed by its config name — set integrations.<key>.enabled to turn one off
(or, for permission, on):
| Key | Package / source | enabled default |
|---|---|---|
api_resources |
Laravel API resources (built in) | true |
eloquent |
Eloquent models (built in) | true |
rate_limit |
Laravel rate limiting (built in) | true |
spatie_data |
spatie/laravel-data |
true |
query_builder |
spatie/laravel-query-builder |
true |
json_api_paginate |
spatie/laravel-json-api-paginate |
true |
laravel_actions |
lorisleiva/laravel-actions |
true |
timacdonald_json_api |
timacdonald/json-api |
true |
sanctum |
laravel/sanctum |
true |
passport |
laravel/passport |
true |
permission |
spatie/laravel-permission |
false |
The table below lists the additional options each bag accepts beyond enabled.
| Bag | Key | Default | Effect |
|---|---|---|---|
| any | enabled |
true (false for permission) |
Turn the integration on/off for this document. Contributes only when the package is installed and this is true. |
api_resources |
wrap |
each resource’s own $wrap |
false never wraps (global withoutWrapping()); true → 'data'; a string forces that key; omit → each resource’s static $wrap. |
sanctum |
modes |
['token','stateful'] |
Which Sanctum schemes to expose. |
sanctum |
cookie |
session.cookie |
Stateful cookie name. |
passport |
url |
app.url |
oauth2 flow base URL. |
query_builder |
pagination_terminals |
[] |
Extra method names that count as paginating terminals during the trace — on a query-builder receiver only. |
query_builder |
filter_descriptions |
[] |
Filter kind → the sentence that leads that kind’s description, merged over the built-in table: a kind you leave out keeps its default. %field% is the one supported token and interpolates the filter’s public name; a sentence with no token is published as written. A key naming no filter kind reports config.unknown-filter-kind. An entry comment still wins over it, and the whereIn/null/separator/default notes are still appended — see overriding the generated prose. |
permission |
enabled |
false |
Opt in to document role:/permission: requirements (x-permissions). Off by default so authorization names are not published unintentionally. |
export
Section titled “export”export: path: 'docs/openapi.json'path is the default output location for docuccino:export and the file
viewer.source: artifact serves. On its own it means one artifact, in OpenAPI 3.2.
To emit several artifacts from one build, list targets instead:
export: targets: - { format: 'openapi-3.2', path: 'docs/openapi.json' } - { format: 'openapi-3.1', path: 'docs/openapi-3.1.yaml' } - { format: 'full', path: 'docs/api.full.json' } - { format: 'postman', path: 'docs/collection.json' }Analysis is the expensive half of a build, so three targets cost one analysis and three emits — not three runs of everything.
| Field | Effect |
|---|---|
format |
One of the emit formats. Unknown values are an error, never a fallback. |
path |
Where this artifact lands. Relative paths resolve against base_path(), and missing directories are created. |
Rules the command enforces before it builds anything:
targetsreplacespath. Set both andpathwrites nothing; you get oneconfig.export-path-ignoredinfo diagnostic telling you to delete it.- One target per format. Two
openapi-3.2targets are rejected, which is what keeps--formatand the viewer’s artifact each resolving to exactly one file. - No two targets may write the same file, in one document or across documents — one would clobber the other.
- The extension picks the serialization. A
.yamlor.ymlpath emits YAML; anything else emits JSON. There is noyamlkey, because the path already says it. fullandpostmanhave no YAML form, so a.yamlpath on either is an error rather than a.yamlfile holding JSON.
A broken target list fails the command with a config.export-* error before the build runs, so
you never pay for an analysis to find out a filename was wrong.
One more key sits beside them, shaping what the emitters write rather than where:
export: path: 'docs/openapi.json' mock_faker_key: 'x-faker'mock_faker_key is the member every #[Mock] faker
expression is published under in the OpenAPI artifacts. Unset — the default — leaves them out, so a
bare export is pure OpenAPI. The full format carries the hints whichever way this is set, and
turning it on rewrites no byte of the full document: it shapes the projection, never the document, so
configHash and the fragment cache are untouched.
versioning
Section titled “versioning”versioning: 'none' # 'semver' | 'date' | 'none'The policy docuccino:diff --enforce applies to this document. semver requires a major version
bump for breaking changes; date requires a new date version; none never fails on versioning. See
docuccino:diff.
It has a second job on a document that declares api_version: it names the order two
versions are in, which is the order a change list applies in. A value that cannot read your versions —
semver over dates, say — leaves them unordered, so no changes are applied and the version
document comes out saying what the code says (versioning.unordered). Say nothing and Docuccino reads
the order off the versions themselves, which is the safer default of the two.
Extensions
Section titled “Extensions”extensions: [] # e.g. ['App\Docs\InvoiceTotalsExtension']Class-strings resolved from the container and merged with programmatic Docuccino::extend()
registrations at build time, never at boot. See extension authoring.
The keys below configure the document lints. Linting the document introduces what each pass catches and when to silence one.
lint: leakage: enabled: true allow: [] # e.g. ['reset_token', '/components/schemas/Invoice/properties/status'] # patterns: { sortcode: 'a bank sort code', iban: 'an IBAN' } descriptions: enabled: false allow: [] # e.g. ['GET /api/ping'] operation_ids: enabled: true allow: [] # e.g. ['GET /api/ping', 'list users'] tags: enabled: false allow: [] # e.g. ['Internal'] # vacuous_union: # enabled: true # allow: [] # e.g. ['GET /api/ping'] # examples: # enabled: true # allow: [] # e.g. ['/components/schemas/Invoice/properties/status/example'] # unpinned_redirect: # enabled: true # allow: [] # e.g. ['GET /auth/callback']Every lint is diagnostics-only — none of them can change a byte of the emitted document — and every default below is set by where the rule was measured to fire, not by whether it would be correct. A rule that fires where you can do nothing teaches you to ignore the channel, and takes the useful warnings with it.
| Rule | Default | Warns about |
|---|---|---|
leakage |
on | A schema property, example or default that looks like a credential. |
descriptions |
off | An operation publishing neither a summary nor a description. |
operation_ids |
on | An operationId a generated client can’t name a method after. |
tags |
off | A tag your operations carry that tags.definitions never declares. |
vacuous_union |
on | An anyOf whose unconstrained branch — {}, or the true that means the same — accepts anything, so its typed branches add no constraint. |
examples |
on | A published example the schema beside it rejects. Its allow entries are JSON pointers, or the label the message names. |
unpinned_redirect |
on | A redirect the document doesn’t say exactly one thing about: the 3XX range alone, or the range still standing beside a concrete 3xx. |
Data leakage
Section titled “Data leakage”The data-leakage pass checks three things.
Property names. A name that looks sensitive (password, token, secret, api_key, …) warns with
its JSON pointer. Names normalize to lowercase alphanumerics, so api_key, apiKey and API-KEY are
one token.
Parameter names. The same heuristics read the name of every parameter your document publishes — an
operation’s own, a path item’s shared ones, and components.parameters. Only query and path
parameters are read: a URL is recorded in access logs, proxy logs, browser history and outbound
Referer headers, which is what makes ?api_key=… the well-known anti-pattern this warning is about.
A header or cookie parameter is where a credential is supposed to travel, so neither warns. A
parameter written as a $ref is reported once, at the component it points at.
Published values. Every leaf under example, examples, const, enum and default is matched
against known credential shapes — the check that catches a real secret folded out of a class constant
under an innocent member name:
| Recognized shape | Matches |
|---|---|
| A PEM private key | -----BEGIN PRIVATE KEY----- and its labeled variants |
| An AWS access key id | AKIA…, ASIA… |
| A GitHub token | ghp_…, github_pat_… |
| A live Stripe secret key | sk_live_…, rk_live_… |
| A Slack token | xoxb-…, xoxp-… |
| A JWT | eyJ….….… |
| A URL with embedded credentials | postgres://user:password@host/db |
The warning names the member and pointer, never the matched text — echoing the secret would only move it into your build log. Shapes only: there is deliberately no entropy scoring (UUIDs, hashes and base64 payloads are what good examples look like) and no internal-hostname heuristic (a private domain is a legitimate server URL).
| Key | Default | Effect |
|---|---|---|
enabled |
true |
Turn the pass on/off. |
allow |
[] |
Safelist by property name, parameter name or JSON pointer. Silences all three kinds of finding; for a value, use the pointer. A name silences the lint only — the response recorder redacts by name regardless, and honours a pointer alone. |
patterns |
built-in table | Extra token → label heuristics for property and parameter names, merged over the built-in table (key = normalized token, matched when a name contains it). |
Descriptions
Section titled “Descriptions”lint.missing-description warns on an operation that publishes neither a summary nor a
description, so the document never says what the endpoint does. It’s the one completeness hole a
reader can’t work around — nothing else in the document carries that sentence. Write a docblock on the
action (its first line becomes the summary, the rest the description) or put one in an
overlay; the warning carries the file and line the action was
recovered from, so you can go straight there.
Off by default, and deliberately: on an API that documents nothing this fires once per operation, which is a backlog rather than a diagnostic. Turn it on when you’re closing the gap and want the list.
Deliberately operations only. Parameters and schema properties were measured on a real route set and
fire on 40% and 98% of their populations respectively, almost all of it where there’s nothing to write —
a route-model-bound {invoice}, a column whose name is the whole story.
Webhooks are operations too, and are checked the same way — the
docblock to write is the one on the #[Webhook] class. A webhook is named POST webhooks.invoice.paid
in the message and in the safelist, since it’s published under a name rather than a path.
| Key | Default | Effect |
|---|---|---|
enabled |
false |
Turn the pass on/off. |
allow |
[] |
Safelist by operation signature (GET /api/ping, POST webhooks.invoice.paid) or by operationId. |
Operation ids
Section titled “Operation ids”lint.operation-id-style warns on an operationId a generated client can’t turn into a method name:
empty, starting with a digit, or carrying anything outside letters, digits and the separators
. - _ @. Your consumers meet the id as the function they call, so a broken one either fails
codegen or arrives renamed to something nobody wrote.
The alphabet is wider than an identifier’s on purpose: ., - and @ are what the route-name and
controller-method id strategies mint, and every generator in this space folds them.
So nothing Docuccino produces can trip this rule — a finding is always on a string somebody typed, in
an #[OperationId], a route name or a
#[Webhook] name, and can be typed differently. That’s why it’s on by
default.
A webhook is published under its name, so that name is its operationId and renaming the attribute is
what fixes it — #[OperationId] doesn’t reach a webhook. The message and the safelist name it
POST webhooks.invoice.paid.
Duplicate ids are a separate check with a better vantage point: route.duplicate-operation-id reports
them where the pair is met, naming both routes. document.duplicate-operation-id says the same thing
about the emitted artifact, which is where a collision an overlay wrote shows up. Neither fails an
export — controller-method publishes one id per controller action, so several routes onto one action
collide by design.
| Key | Default | Effect |
|---|---|---|
enabled |
true |
Turn the pass on/off. |
allow |
[] |
Safelist by operation signature (GET /api/ping, POST webhooks.invoice.paid) or by the id itself. |
Undocumented tags
Section titled “Undocumented tags”lint.undocumented-tag warns on a tag your operations carry that tags.definitions
never declares, so it reaches the reader as a bare heading among tags that have a summary, a
description and a place in the hierarchy. A tag a webhook puts on
itself with #[Group] counts, whether or not any route carries it.
It says nothing at all until the document declares at least one tag. Undeclared tags are the normal, correct state for an API that never curated them, and “you forgot this one” only means something once the others have descriptions.
Off besides that guard, for the case the guard can’t tell apart: declaring a few nav parents by hand
and letting the rest derive from controller names is a deliberate shape, and firing once per derived tag
there would be noise. Turn it on when your definitions are meant to be the complete set.
| Key | Default | Effect |
|---|---|---|
enabled |
false |
Turn the pass on/off. |
allow |
[] |
Safelist by tag name. |
Vacuous union
Section titled “Vacuous union”lint.vacuous-union warns on an anyOf carrying an unconstrained branch — {}, or the true that says
the same thing: that branch accepts anything, so the typed branches beside it add no constraint and the
schema validates like mixed while reading like a contract. It’s the trace of an honest widening — one arm of the union recovered as
“anything” — and what it cost is the shape your consumer would otherwise have validated against. The
finding names the operation and the JSON pointer, so you can go to the arm and pin it with a return
docblock or a #[Response] on the action.
The shape itself is kept: the typed branch still tells a reader what the value usually is, and dropping
it would lose that. A branch carrying only annotations — a description, a title — counts as
constrained, since it says something a reader acts on.
On by default. Measured against a real application’s export it fired once across 221 operations, all of
it where the author could act: a union goes vacuous only where recovery gave up on an arm, which is
exactly the case worth a line. It also can’t false-fire on your data — the walk skips x- members and
the values of enum, examples, example, default and const, so an example that happens to be
shaped like a union is read as the value it is.
| Key | Default | Effect |
|---|---|---|
enabled |
true |
Turn the pass on/off. |
allow |
[] |
Safelist by operation signature (GET /api/ping, POST webhooks.invoice.paid) or by operationId. |
Diagnostics
Section titled “Diagnostics”diagnostics: accept: [] # e.g. ['eloquent.no-columns']accept is the list of diagnostic codes you’ve read and decided
to live with. An accepted diagnostic still prints, marked accepted and counted in a closing
line, and stops counting towards --fail-on. That’s
what makes a stricter gate adoptable: you can turn --fail-on=info on today, accept the codes you
can’t act on — a vendor model with no readable columns, a validation rule that’s genuinely a closure
— and still have the gate catch everything new.
diagnostics: accept: - 'eloquent.no-columns' - 'validation.rule-unrecoverable'The unit is a whole code, not a code at one route: a code names a cause, which is the thing you decide about, and a list scoped by route would be a second copy of your application’s shape that goes stale on the next rename.
An error is never accepted. An error means the document is wrong or the build lost a whole tier
of facts, so a code that reaches an error keeps failing the run, and the build says so with
config.accept-refused. Accepting a code that’s sometimes an error still covers its warning,
info and hint reports.
Nothing accepted is invisible, and the list can’t rot:
| Where | What you see |
|---|---|
| The diagnostic itself | [info, accepted] eloquent.no-columns: …, exactly where it always printed. |
| The end of each document’s block | Accepted, so --fail-on ignores them: eloquent.no-columns (12) — every entry that fired, with its hit count. |
| An entry that fired as an error | config.accept-refused — acceptance didn’t apply, and the run still failed. |
| An entry nothing reported | config.accept-unused — the cause is fixed, or the code is misspelled. Delete the line. |
config.accept-unused is checked once a run has built every document, so docuccino:export billing never reports an entry the document it skipped fires on.
| Key | Default | Effect |
|---|---|---|
accept |
[] |
Diagnostic codes that print but never fail --fail-on. Errors are never accepted. |
Engine
Section titled “Engine”engine: mode: 'in-process' # DOCUCCINO_ENGINE overrides this # memory_limit: '2G' # project_paths: ['app'] # config: 'phpstan.neon'| Key | Default | Effect |
|---|---|---|
mode |
in-process |
in-process runs PHPStan; null skips inference entirely (docblocks and attributes still work). Those are the two modes. Set it per environment with DOCUCCINO_ENGINE. A boot failure degrades to no inference rather than failing the build. |
memory_limit |
unset | PHP memory limit for inference, applied on console builds only. Only ever raises — an already-higher or unlimited process is left alone, and -1 isn’t accepted here — so the knob can’t introduce the exhaustion it exists to prevent. --memory-limit on the build commands overrides it. |
project_paths |
every autoload PSR-4 root |
The descend scope: directories the engine follows for general interprocedural analysis (throw classification, inline Validator::make() rules). Bounds descent into callee bodies. Unset, it is every PSR-4 source root your composer.json declares under autoload — for a stock Laravel application that is app/ plus the two database/ roots the skeleton maps, and it picks up your Modules\…/Domain\… roots if you map any. Set it only to narrow descent. |
config |
unset | Your own PHPStan config file, included by the one the engine writes for itself. Relative to the application base path. A file that isn’t there warns (config.engine-config-missing) and inference runs without it. |
PHP cannot catch memory exhaustion, so it’s the one failure that kills a build instead of degrading —
memory_limit and --memory-limit exist to prevent it. Full walkthrough:
the export runs out of memory.
Inference needs the dev-only docuccino/inference-phpstan package. Without it, every mode but null
degrades to no inference and each export carries one engine.not-installed warning naming the
install command — null is the explicit opt-out and stays silent.
Any other value warns (engine.mode-unknown) and runs in-process — a typo in DOCUCCINO_ENGINE
costs you a diagnostic, never a failed build.
cache: enabled: false # the fragment cache: incremental builds, off by default # path: null # fragment cache directory (defaults to storage_path('docuccino/fragments'))| Key | Effect |
|---|---|
enabled |
Turns on the fragment cache for incremental builds. DOCUCCINO_FRAGMENT_CACHE overrides it, which is how docuccino:watch turns it on for the builds it drives without changing your config. The key hashes the tool/spec/identity-algo versions, the document it is being built for, doc config, the resolved extension list — each extension paired with its package version and a digest of the files it, its parents and its traits are written in, so editing your own extension rebuilds — route signature, the build environment, and every dependency file the engine reported — so invalidation is sound even for a Query class three calls deep. Assembly/canonicalize/validate always run fresh. A build whose routes are all warm never boots the analyzer at all; Speeding up builds covers when to turn this on. |
path |
Fragment cache directory (defaults to storage_path('docuccino/fragments')). Docuccino drops a .gitignore into the directory it creates — the same * / !.gitignore pair Laravel ships inside storage/ — so cached fragments stay out of your repository. An existing .gitignore is never overwritten. |
The third member of the cache family, cache.store, is not here: it names a
Laravel cache store that a viewer request reads, so it lives in the file a request can reach.
Settings left in config/docuccino.php
Section titled “Settings left in config/docuccino.php”Every build setting above is read from docuccino.yaml and from nowhere else. config/docuccino.php
keeps enabled, cache.store and each document’s viewer, and a build key still sitting beside them
is not merged and not given precedence — it is detected and reported, and that is all. Merging the
two would be worse than ignoring one: the symptom of a silent precedence rule is a document that
quietly stops matching the file you edited.
So the build tells you, in one of two ways:
- No
docuccino.yamlat all, and build keys inconfig/docuccino.php— every command that builds a document refuses withconfig.not-migratedbefore it starts. The document would otherwise be assembled from defaults and look entirely plausible. - A
docuccino.yamlbeside the leftovers —config.stale-php-keyswarns and names them. Nothing is lost; the YAML says what the document is.
Both name the exact keys, so moving them is a copy under the same names into docuccino.yaml, then a
delete from config/docuccino.php. Four keys are the exception.
Two were renamed. Copied across as they were spelled, they name no setting, and the build reports
them as config.unknown-setting:
documents.*.security.auto_detect_middlewareis nowdocuments.*.security.auth_middleware.engine.neonis nowengine.config— the engine takes any analyser configuration file, whatever its extension.
Two have no equivalent, and they cost different things:
documents.*.representation.listshad no reader — both of its values emitted the same document — so there is nothing to carry. Delete it.documents.*.routes.closurefiltered routes, and a closure has no form in a configuration file.routes.filternames a class instead: move the predicate into aRouteFilter, and until you do, the routes that closure held back are documented again.
Check your env() calls. config/docuccino.php is PHP and can read a setting through env();
docuccino.yaml cannot. Where a setting was environment-dependent, write the value the file should
carry — and note that DOCUCCINO_ENGINE and DOCUCCINO_FRAGMENT_CACHE still override docuccino.yaml,
so those two levers keep working without a key at all.
And check your values. config/docuccino.php can hold an enum case, a closure, a resource or a
date; docuccino.yaml holds numbers, strings, booleans, and lists and maps of those. A setting whose
value has no form in the file is better left out — an absent key takes its documented default, where a
value changed on the way in builds a document you never configured.
Boot configuration
Section titled “Boot configuration”config/docuccino.php, published into your application’s config/ directory. Laravel loads every
file in there on every boot, so this half is deliberately small: the master switch, each document’s
viewer, and the cache store a viewer request reads.
return [ 'enabled' => env('DOCUCCINO_ENABLED', true), 'documents' => [ 'default' => [ 'viewer' => [ /* … */ ], ], ], 'cache' => ['store' => null],];| Key | Default | Effect |
|---|---|---|
enabled |
env('DOCUCCINO_ENABLED', true) |
Master switch. When false, every command except docuccino:clear aborts with a notice and exits non-zero, and the runtime viewer endpoints (/docs/*) are not registered at all. Lets you disable generation and serving in an environment without removing config. |
cache.store |
null |
Laravel cache store name for the runtime document cache warmed by docuccino:cache and served to a viewer whose source is cache. null uses the application’s default store. |
The documents map here is keyed the way docuccino.yaml keys it, and holds nothing but each
document’s viewer. A document declared there and absent here has no page to serve, which is what an
export-only document is; a viewer keyed by a document the build does not define registers routes that
fail every request, and the build says so with config.viewer-orphan.
The file is plain data — no imports, no class references, env() the only call it makes — so it stays
safe to load where Docuccino itself isn’t installed. A --no-dev production boot loads every file in
config/ after pruning dev packages, and a class reference here would fatal it.
viewer
Section titled “viewer”'viewer' => [ 'route' => '/docs/api', // null disables the runtime endpoints for this document 'gate' => null, // Gate ability name; null = local environment only 'middleware' => ['web', 'throttle:60,1'], 'source' => 'generate', // generate | artifact | cache // 'driver' => 'scalar', // scalar | redoc, or a driver you registered // 'cdn' => false, // true loads the driver's script from a CDN instead of the bundled asset // 'configuration' => [], // passed verbatim to Scalar's data-configuration (theme, layout, …)],| Key | Default | Effect |
|---|---|---|
route |
'/docs/api' |
Base path for the viewer routes: the HTML page, the .json spec, the active driver’s asset, and the /reload channel a docuccino:watch session refreshes the page through. null disables them for this document. |
gate |
null |
Gate ability guarding all four routes — the HTML page, the .json spec, the asset and the reload channel. null = available only in the local environment. Every driver goes through it. |
middleware |
['web', 'throttle:60,1'] |
Middleware for the viewer routes. Keep throttle when exposing the (potentially expensive) spec endpoint publicly. See the warning below if your app is multi-tenant or domain-gated. |
source |
'generate' |
generate rebuilds on every request (fine for local/gated); artifact re-emits the committed export.path; cache serves the docuccino:cache-warmed payload (cold cache falls back to generate). |
driver |
'scalar' |
Which renderer serves the HTML page: scalar (with a try-it-out console) or redoc (reference only), or the name of a driver you registered. An unregistered name falls back to scalar and logs a warning. |
cdn |
false |
true loads the active driver’s script from jsDelivr instead of the bundle shipped with the package. |
configuration |
[] |
Passed verbatim to Scalar’s data-configuration — theme, layout, hideModels, and the rest of Scalar’s own options. Ignored by drivers that take no page configuration. |
Nothing under viewer shapes the document: it is boot-time wiring, read only by the runtime endpoints
and the console. So it stays out of the document’s configHash exactly as export does —
moving a route, naming a gate or switching drivers rewrites no emitted byte and retires no warm
fragment.