Skip to content

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.

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 one
extensions: []
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 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:
# 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:
- { 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:
include: ['api/*']
exclude: []
# filter: App\Docs\PublicRoutes
include_vendor: false

Route 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:
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: '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: { 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:
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: { recordings: 'docs/recordings' } # absent by default

Points 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: { log: 'storage/docuccino/coverage' } # the default when absent

Where 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: [] # 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.

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:
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 with

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

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 default

Every 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:
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:

  • targets replaces path. Set both and path writes nothing; you get one config.export-path-ignored info diagnostic telling you to delete it.
  • One target per format. Two openapi-3.2 targets are rejected, which is what keeps --format and 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 .yaml or .yml path emits YAML; anything else emits JSON. There is no yaml key, because the path already says it.
  • full and postman have no YAML form, so a .yaml path on either is an error rather than a .yaml file 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: '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: [] # 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.

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

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.

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.

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.

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

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.yaml at all, and build keys in config/docuccino.php — every command that builds a document refuses with config.not-migrated before it starts. The document would otherwise be assembled from defaults and look entirely plausible.
  • A docuccino.yaml beside the leftovers — config.stale-php-keys warns 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:

Two have no equivalent, and they cost different things:

  • documents.*.representation.lists had no reader — both of its values emitted the same document — so there is nothing to carry. Delete it.
  • documents.*.routes.closure filtered routes, and a closure has no form in a configuration file. routes.filter names a class instead: move the predicate into a RouteFilter, 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.

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' => [
'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.