Commit the output
Deterministic bytes mean a clean PR diff every time. The committed spec is your source of truth.
The default configuration rebuilds your document on every request — perfect for local development, wrong for production. This guide covers the production story: generate in CI, commit the artifact, serve it without re-analyzing, gate who can see it, and let CI catch breaking changes.
Generate in CI and commit the artifact. Docuccino’s output is deterministic, so a committed
docs/openapi.json is a reviewable, diffable record of your API.
php artisan docuccino:export --fail-on=warningServe the committed file, not a fresh build. Point the viewer at the artifact so no request ever triggers analysis:
// config/docuccino.php → documents.default.viewer'source' => 'artifact', // re-emits the committed export.path; never re-analyzesThe viewer is the one half of Docuccino’s configuration a request reads, so it stays in
config/docuccino.php. Everything that shapes the document is read once per build, from
docuccino.yaml.
Gate the viewer route so only the right people reach it (below).
Diff on every change to enforce your versioning policy in CI (below).
Generation is safe to run in any environment, including a CI container with no services attached: the pipeline reads types, so it makes no database queries, sends no mail, and dispatches no jobs. Booting your app to read its route table is the one thing that does touch your drivers, so if a Redis, database or queue exception fires before analysis starts, point those drivers at their in-memory equivalents — the app won’t boot during export has the one-line fix.
config:cachedocuccino.yaml is never read at boot and never cached, so it costs a production box nothing at all.
Only a command opens it, and a production box runs none.
config/docuccino.php is loaded on every boot, and every key in it is plain data — strings, arrays
and booleans. Nothing in the file is a closure, so php artisan config:cache serializes it like any
other config file and a box booting from a cached config reads exactly what you committed. That is
not a detail: config:cache serializes the whole config array with var_export(), and a closure has
no serializable form, so one closure anywhere under docuccino would fail the command for your
entire application — not just for Docuccino — with “your configuration files could not be
serialized”.
A YAML file cannot hold a closure at all, which is why the two build settings that would most
obviously want one name a class instead: route filtering
(routes.filter) and a custom tag mapper
(tags.mapper). Both are resolved out of the container, so
whatever a closure would have captured becomes a constructor dependency.
Because the analysis lives in docuccino/inference-phpstan and you install it as a dev dependency,
composer install --no-dev deploys the adapter alone: no PHPStan, no Larastan, nothing that reads
your source at runtime. The production dependency chain is docuccino/laravel → docuccino/core →
a JSON-Schema validator, a YAML parser and two small parsing libraries. Serving from artifact or
cache then costs one file read.
That is also why the export belongs in CI: the machine that has your dev dependencies is the one that can analyze your code. A production box that only serves the committed document never needs to.
This guide assumes your app serves the viewer. If it doesn’t — because you read the docs only in development, or you publish the spec to an external host — install the adapter as a dev dependency too and production ships nothing of Docuccino at all. Everything below about generating and diffing in CI still applies; only the viewer sections stop mattering. See the installation scenarios.
viewer.source decides where the served spec comes from. In production, serve pre-built bytes:
source |
Where the spec comes from | Use it when |
|---|---|---|
generate |
Rebuilt on every request | Local development, or a gated internal viewer on a small app |
artifact |
A committed export target, re-emitted | You commit the spec — the usual production answer |
cache |
The docuccino:cache-warmed payload |
High-traffic or public viewers; you’d rather warm on deploy than commit |
The viewer guide has the rest of the viewer’s options.
For cache, warm it on deploy and clear it when you regenerate:
php artisan docuccino:cache # build and store each document's payloadphp artisan docuccino:clear # forget the cached payloadSet the backing store with cache.store. A cold cache falls back to generating the spec and logs a
warning, so a forgotten warm-up degrades loudly instead of serving nothing. See the
commands reference.
By default the viewer is reachable only in your local environment. To expose it anywhere else, name a
viewer.gate ability and define it in a service provider — the gate guards the HTML, .json and
asset routes, and you should keep throttle in viewer.middleware when the spec endpoint is public.
The viewer guide walks through it step by step.
The gate is checked before any viewer driver runs, so switching drivers — or registering one of your own — changes what the page looks like and nothing about who can reach it.
To turn the runtime viewer off for one document, set its viewer.route to null — export still
works. To turn Docuccino off entirely in an environment, use the master switch:
DOCUCCINO_ENABLED=falseWith enabled false, no viewer routes are registered at all and every command except
docuccino:clear aborts with a non-zero exit — so a production box can ship the package without
serving or generating anything.
Regenerate whenever the code that shapes your API changes — new routes, changed validation, new
resources or error handling. The natural home for that is a pull-request job: export, then diff the
fresh document against the committed one and hold the changeset to your
versioning policy. How that comparison works — what
it calls breaking, and why it pairs nodes by identity rather than by position — is
Diffing your API.
It’s the same job when your docs live on an external host: export with --out= to wherever your
upload step reads from, and add that upload after the checks below pass.
# 1. Regenerate; fail if anything got worse than a warning.php artisan docuccino:export --fail-on=warning
# 2. Structural check: the built document, and each artifact you export, against their schemas.php artisan docuccino:validate
# 3. Compare with the artifact on the base branch and enforce the versioning policy.php artisan docuccino:diff docs/openapi.json --against=origin/main --enforcename: API docson: pull_request
jobs: spec: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 with: fetch-depth: 0 # --against reads the base branch out of the clone. - uses: shivammathur/setup-php@v2 with: php-version: '8.4'
# Dev dependencies included, deliberately: the analysis engine is one of them. - run: composer install --no-interaction --prefer-dist - run: cp .env.example .env && php artisan key:generate
# Diff BEFORE overwriting: --against reads the old side out of git. - run: php artisan docuccino:diff docs/openapi.json --against=origin/${{ github.base_ref }} --enforce - run: php artisan docuccino:export --fail-on=warning - run: php artisan docuccino:validate
# The committed artifact must match a fresh build — output is deterministic. - run: git diff --exit-code docs/openapi.json--against reads the old side out of git (git show origin/main:docs/openapi.json), so the job
works on a clean checkout without a second build. Point it at the base branch, not at HEAD:
against HEAD the old side is the pull request’s own committed artifact, so a contributor who
regenerated it — which the last step below requires — leaves the two sides identical and the gate
with nothing to say. Against the base branch the changeset is exactly what the pull request does.
The final git diff --exit-code is the step that keeps contributors honest: because identical code
produces identical bytes, a stale committed spec fails the build.
That step is also how a Docuccino upgrade announces itself. Your code hasn’t changed, so any byte that moves came from the new release — regenerate, read the diff, and commit it as its own change. The changelog records what each release altered, per package.
A docs job re-analyzes a mostly-unchanged application on every pull request, which is exactly what the fragment cache is for. Restore one directory between runs and the job rebuilds only the operations the branch touched — and when the branch touches none of them, it never starts the analyzer.
Getting the cache key exactly right doesn’t matter here, which is unusual enough to say plainly.
Every stored fragment carries a content hash of each file it was built from, and every lookup
re-verifies all of them. A restored cache that turns out not to match the checkout produces misses —
never a wrong document — so a generous restore-keys prefix is safe. Reuse yesterday’s cache, another
branch’s cache, or nothing at all: the worst case is the full build you’d have run anyway.
Let the environment turn it on, so only CI (and whoever wants it locally) pays for a store.
Leave cache.enabled off in docuccino.yaml and set the variable that overrides it — a value a
run has an opinion about is exactly what the environment is for, and there is no config edit to
review:
DOCUCCINO_FRAGMENT_CACHE=trueCache storage/docuccino. That one directory holds everything Docuccino writes for its own
use — the fragments, and the analyzer’s compiled container and scratch files — so one cache entry
covers the lot:
# .github/workflows/api-docs.yml → jobs.spec, alongside the steps aboveenv: DOCUCCINO_FRAGMENT_CACHE: true
steps: # Restore before the artisan commands; the post-job step saves it again. - uses: actions/cache@v4 with: path: storage/docuccino key: docuccino-${{ hashFiles('composer.lock') }}-${{ github.sha }} restore-keys: | docuccino-${{ hashFiles('composer.lock') }}- docuccino-Folding composer.lock into the key is a convenience, not a correctness measure: a dependency
upgrade invalidates every fragment anyway, so this just avoids carrying a store that can no longer
hit.
An OpenAPI artifact is the friendliest thing to review, and it’s what most tooling wants. Commit the
full document instead (--format=full) when you also want stable identities and provenance in the
repo — a richer diff, at the cost of a noisier file. Either way, pair it with --provenance=none if
you’d rather source line numbers didn’t churn in your PRs.
If your API is served per tenant on a subdomain, describe it once in docuccino.yaml with a server
variable rather than a server per tenant:
documents: default: servers: - url: 'https://{tenant}.example.com' variables: tenant: { default: 'acme', description: 'Tenant slug' }The viewer renders the variable as an editable field, and the value flows into try-it-out requests.
The default is what every OpenAPI version requires of a variable and what the viewer starts from, so
give each one a value you actually serve — see servers
in the configuration reference.
Routes registered under Route::domain() answer on that host and no other, so they carry it into the
document as an operation-level servers entry. A generated client then calls them where they live
instead of on the document’s base URL.
Route::domain('admin.example.com')->group(function () { Route::get('api/invoices', [Admin\InvoiceController::class, 'index']);});paths: /api/invoices: get: servers: - url: https://admin.example.comBinding a host swaps the host out of your document’s server URL and nothing else. The first of your
configured servers that states a scheme supplies the
scheme, the port and the base path — so https://api.example.com/v1 beside the group above publishes
https://admin.example.com/v1, not https://admin.example.com. That matters because an
operation-level servers array replaces the document-level one rather than adding to it: a base path
dropped here is dropped for that operation, and a generated client calls a URL your API does not serve.
A local http:// app documents its hosted routes over http for the same reason.
A templated host ({tenant}.example.com) becomes a server variable, the same shape as the
document-level example above; any variable your base path still names is carried over beside it, since
an operation-level server has to define every variable in its own URL. Only those are carried: a
variable the document declares for a segment this operation no longer has belongs to the host that was
just replaced, so it is left behind rather than published against a URL that never mentions it.
A variable derived from the host defaults to the placeholder’s own name — {tenant} defaults to
tenant — which is as close to a value as the routes can honestly get, and it wins if the document
also declares a variable of that name. The whole entry is written at the fallback layer, the lowest
there is, so an overlay replaces it outright when the real
values are something you’d rather state than derive.
If the host itself comes from the environment — Route::domain(config('app.admin_domain')) with
ADMIN_DOMAIN=admin.myapp.test — the build reports it as a
config.machine-dependent-value
warning, exactly as it does for an unpinned app.url.
Commit the output
Deterministic bytes mean a clean PR diff every time. The committed spec is your source of truth.
Gate then cache
Serve artifact or a warmed cache behind a gate — never generate on a hot public route.