Multiple documents
One application often has more than one API: a public API and an admin API, or a v1 you still support
alongside a v2. Docuccino models each as an independent document — its own route filters, info,
security, viewer, and export target.
Defining documents
Section titled “Defining documents”documents is a map, and the keys are yours. The published config ships one entry, default; add as
many as you need. Each is a full, self-contained pipeline:
documents: public: info: { title: 'Public API', version: '2.0.0' } routes: { include: ['api/*'], exclude: ['api/admin/*'] } error_responses: 'default' export: { path: 'docs/public.openapi.json' } admin: info: { title: 'Admin API', version: '1.4.0' } routes: { include: ['api/admin/*'] } error_responses: 'default' export: { path: 'docs/admin.openapi.json' }Each document’s viewer is the one half that lives in config/docuccino.php, keyed the same way,
because its routes are registered on every boot:
'documents' => [ 'public' => ['viewer' => ['route' => '/docs/api']], 'admin' => ['viewer' => ['route' => '/docs/admin', 'gate' => 'viewAdminDocs']],],Here the two documents split by route glob, expose their viewers on different routes (the admin one
gated), and export to different files. The same pattern covers a v1 and a v2 you serve on separate
route prefixes — one document each, over api/v1/* and api/v2/*, with its own info.version.
The two maps are keyed independently, so they can disagree — and a build says so rather than leaving
you to find out from a 404. A document with no viewer entry simply has no page to serve, which is
what an export-only document is; a viewer keyed by a document docuccino.yaml doesn’t define
registers routes that fail every request, and the build reports config.viewer-orphan.
If instead your versions share one set of routes and differ only in the shape of what they return, API versioning derives each version’s document from the changes you declare, so you write the difference down once rather than maintaining a document per version by hand.
What’s per document, and what’s global
Section titled “What’s per document, and what’s global”Per document (documents.<key>.*) |
Global (top level) |
|---|---|
info, servers, routes, security |
engine — inference mode and project paths |
error_responses, tags, representation |
extensions — your own extension classes |
content, overlays, versioning |
lint — document lint rules |
integrations — per-document toggles |
diagnostics — the codes you have accepted |
export |
on_route_error, cache.enabled |
viewer — in config/docuccino.php |
enabled and cache.store — in config/docuccino.php |
So the analysis engine, your extensions and the lint rules are shared, while everything about what a document contains and where it goes is yours to set per document.
Assigning routes to documents
Section titled “Assigning routes to documents”Route selection is a filter chain, and every step must pass:
# docuccino.yaml, under documents.<key> — include, then exclude, then an optional filter classroutes: include: ['api/v2/*'] exclude: ['api/v2/internal/*'] filter: App\Docs\NamedRoutesPatterns match the URI without its leading slash, using Laravel’s Str::is wildcards.
filter runs last, and is a class the container builds:
namespace App\Docs;
use Docuccino\Core\Extensions\Context\RouteDescriptor;use Docuccino\Core\Extensions\Contracts\RouteFilter;
class NamedRoutes implements RouteFilter{ public function includes(RouteDescriptor $route): bool { return $route->name !== null; }}It receives the discovered route — its methods, uri, name, action, domain and
fully-gathered middleware — and returning false omits it. The key names a class rather than
taking a predicate because a configuration file has no form for one; the container builds it, so
anything the decision needs (a tenant registry, a feature flag) is a constructor dependency.
// Restrict a route (or a whole controller) to named documentsuse Docuccino\Attributes\InDocs;
#[InDocs('public', 'partner')]public function webhook(Request $request): JsonResponse { /* … */ }Without the attribute, a route goes into every document whose globs match it.
#[InDocs] narrows: it’s applied after the globs, so a
route still has to match the document’s routes.include to appear at all. Use it for an endpoint that
several documents’ globs would otherwise sweep up — a shared webhook that belongs in the public and
partner docs but not the admin one.
Two more filters run for every document: #[ExcludeFromDocs] drops a route everywhere, and routes
whose controller lives under vendor/ are skipped unless the document sets
routes.include_vendor: true.
Building them
Section titled “Building them”Every command takes an optional {document} argument. Omit it to run over all documents; pass a key
to run one:
php artisan docuccino:export # export every documentphp artisan docuccino:export admin # export just the admin documentphp artisan docuccino:cache # warm the runtime cache for every documentTwo behaviors worth knowing:
docuccino:diffis the exception. With no{document}it diffs thedefaultdocument only, never all of them — pass the key explicitly for any other document.--outand multiple documents don’t mix.docuccino:export --out=...across every document would have each write clobber the last, so Docuccino refuses: name a document, or configureexport.pathper document.
Per-document results aggregate — if any one document fails, the command exits non-zero. See the commands reference for the full behavior.
The cost of a second document
Section titled “The cost of a second document”Cheaper than you’d expect. Every document in a single command run is built in the same process by the same analysis engine, and the engine keeps its parse of each file — so the second document reuses the expensive work rather than booting PHPStan and re-reading your controllers from scratch. What each document does pay for is its own assembly: its own operations, components, overlays and content tree.