Skip to content

Deploying to production

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.

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

    Terminal window
    php artisan docuccino:export --fail-on=warning
  2. Serve 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-analyzes

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

  3. Gate the viewer route so only the right people reach it (below).

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

Neither configuration file gets in the way of config:cache

Section titled “Neither configuration file gets in the way of config:cache”

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

Terminal window
php artisan docuccino:cache # build and store each document's payload
php artisan docuccino:clear # forget the cached payload

Set 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=false

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

Terminal window
# 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 --enforce

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

  1. 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=true
  2. Cache 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 above
    env:
    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.com

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