The viewer
Docuccino ships an interactive Scalar API reference and serves it straight from your Laravel app — no external service, no CDN required. It has a built-in try-it-out console, so readers can call your API from the docs.
Scalar is the default, not the only option: viewer.driver swaps in Redoc, or
a renderer you write yourself.
Every viewer key on this page lives in config/docuccino.php, under the document it belongs to —
not in docuccino.yaml with the settings that shape the document. These four routes are registered
on every boot, and a gate is checked on every request, so they have to be readable without parsing a
project file. Nothing under viewer changes an emitted byte.
Routes
Section titled “Routes”With the default viewer.route of /docs/api, four routes are registered per document:
| Route | Serves |
|---|---|
GET /docs/api |
The interactive API reference page. |
GET /docs/api.json |
The generated OpenAPI document. |
GET /docs/api/assets/scalar.js |
The viewer script, served from your app (no external CDN). The filename follows the driver — redoc.js for Redoc. |
GET /docs/api/reload |
The live-reload channel, open only while docuccino:watch is running. |
Change the base path with viewer.route, or set it to null to register nothing for that document
(export still works). Setting enabled to false removes the routes for every document at once.
Each document gets its own set, so a second document on /docs/admin is entirely independent — see
multiple documents.
Access control
Section titled “Access control”The viewer is available automatically in your local environment. Anywhere else, it’s closed until you
name a gate:
-
Name a gate ability on the document:
// config/docuccino.php — documents.default.viewer'gate' => 'viewApiDocs', -
Define it in a service provider:
use Illuminate\Support\Facades\Gate;Gate::define('viewApiDocs', fn (?User $user): bool => $user?->isAdmin() ?? false);
The gate guards all four routes — the HTML page, the .json spec, the asset and the reload channel; a
denial is a 403.
The browser fetches the script over the same session as the page, so a reader who is allowed in always
gets it.
The gate runs before any driver does, so it applies to every driver equally, including one you wrote. There is no way to register a viewer that renders for a reader the gate turned away.
Type the gate’s user parameter as nullable if you want guests to reach it (a public docs page behind a token check, say); otherwise Laravel refuses the guest before your closure runs.
Middleware
Section titled “Middleware”viewer.middleware is the full stack for all four routes. The default is:
'middleware' => ['web', 'throttle:60,1'],web gives you session state, which a gate on an authenticated user needs. Keep throttle whenever
the spec endpoint is reachable by anyone who isn’t signed in — with source: generate a request
rebuilds the whole document, which is expensive enough to be worth protecting.
Source modes
Section titled “Source modes”viewer.source decides how the served document is produced:
source |
Behavior |
|---|---|
generate |
Rebuilds the document on every request. Fine for local or gated use. |
artifact |
Re-emits a committed export target — no analysis at request time. Serves an empty document if the file isn’t there, and logs a warning naming it. |
cache |
Serves the docuccino:cache-warmed payload; a cold cache falls back to generate and logs a warning. |
For anything public or high-traffic, prefer artifact or cache — see
Deploying to production.
When a document configures several export targets, the
viewer serves the most faithful JSON one — OpenAPI 3.2 ahead of 3.1, then 3.0, then the full document. That is a
property of the formats, not of your list, so reordering the config cannot change which file is
served. YAML targets are skipped (the endpoint serves application/json), and if a document writes
nothing servable the viewer generates instead and logs why.
A full document works as an artifact source too: it’s re-emitted through the OpenAPI emitter on the
way out, so provenance and internal identities never reach the browser.
Choosing a driver
Section titled “Choosing a driver”viewer.driver names which renderer serves the HTML page. Two ship with the package:
driver |
Renders | Try-it-out console |
|---|---|---|
scalar (default) |
Scalar — a single-column reference with a request console. | Yes |
redoc |
Redoc — a three-panel reference with a persistent left nav. | No |
// config/docuccino.php — documents.default.viewer'driver' => 'redoc',Both read the same /docs/api.json endpoint, so the document itself is identical either way — only
the page around it changes. Pick Redoc when you want the three-panel layout and don’t need readers
firing requests from the docs; stay on Scalar when you do.
Each document chooses its own driver, so an internal document can keep the console while a public one serves the reference-only page.
What each driver shows from an enum
Section titled “What each driver shows from an enum”The document is the same either way, but the two drivers don’t read the same decoration. If your
enums carry #[CaseDescription] prose, this is
where a reader finds it:
driver |
Per-case prose | Codegen member names |
|---|---|---|
scalar |
Beside each value, in parameters and request bodies. A response body’s Show Schema panel shows the schema itself, so the prose is in that JSON rather than beside the value. | Beside the value, as draft = Draft |
redoc |
Beside each value, wherever the enum appears | Not read |
Redoc reads the value-keyed form only — the one the document carries when every case of an enum is described. Describe them all and Redoc shows them all; leave a gap and that enum’s prose reaches Redoc readers only through the document. Scalar reads both forms, so partial prose still shows.
Serving the asset
Section titled “Serving the asset”By default a driver’s script is bundled with the package and served from your own app, so the viewer works on locked-down networks with no outbound access. Both shipped drivers follow that policy — Redoc is not a CDN-only option. Each is a single large file that only changes when you upgrade Docuccino, so it’s served with long-lived immutable cache headers and costs a reader one download.
To load it from a CDN instead — trading the offline guarantee for a byte you don’t serve — set
viewer.cdn:
'cdn' => true, // loads the active driver's script from jsDelivrA document serves only the assets its active driver publishes: switch to redoc and
/docs/api/assets/scalar.js returns a 404, because the name a driver publishes is the whole
allow-list for that route.
The page title comes from the document’s info.title, so the browser tab matches the API rather than
saying “API Documentation”.
Live reload while you work
Section titled “Live reload while you work”Run docuccino:watch beside php artisan serve and
the page you have open refreshes itself whenever a rebuild changes the document:
php artisan docuccino:watchThe page subscribes to /docs/api/reload and reloads when the documentation it is showing is no
longer current. A rebuild that changes no byte leaves it exactly where it was — including your scroll
position and any request you were part-way through in Scalar’s console. The subscriber is spliced into
the HTML the active driver returns, so live reload works on Redoc and on a driver you wrote yourself —
see writing your own driver for the one case it doesn’t.
There is nothing to switch off afterwards: the channel sits behind the same gate and middleware as the rest of the viewer, it answers only while a watch session is running, and a page served without one carries no subscriber at all.
Writing your own driver
Section titled “Writing your own driver”A driver is a class implementing Docuccino\Core\Extensions\Contracts\Viewer: a stable name for
config to select it by, and a render() that returns the page.
namespace App\Docs;
use Docuccino\Core\Extensions\Context\ViewerContext;use Docuccino\Core\Extensions\Contracts\Viewer;
class HouseStyleViewer implements Viewer{ public function name(): string { return 'house-style'; }
public function render(ViewerContext $context): string { return view('docs.house-style', [ 'title' => $context->config->info['title'] ?? 'API Documentation', 'spec' => url($context->config->viewer['route'].'.json'), ])->render(); }}Register it the same way as any other extension, from any service provider:
use App\Docs\HouseStyleViewer;use Docuccino\Laravel\Facades\Docuccino;
Docuccino::extend(HouseStyleViewer::class);…then select it by the name the class returns:
'driver' => 'house-style',render() may return a string of HTML or an Illuminate response, whichever suits the page. The
ViewerContext carries the document’s whole resolved config, so the route, title, servers and viewer
bag are all in reach.
That choice also decides who owns live reload. Return a string and
Docuccino splices the subscriber into your HTML for you, exactly as it does for the shipped drivers.
Return a response and the page is yours end to end — Docuccino won’t rewrite a body you built, because
it has no way to know it is HTML at all. Your page can still subscribe itself: while a
docuccino:watch session is running, <viewer route>/reload sends a reload event carrying a token
naming the current documentation, and reloading when a later token differs is the whole mechanism.
public function render(ViewerContext $context): Response{ return response()->view('docs.house-style', [ 'title' => $context->config->info['title'] ?? 'API Documentation', 'reload' => url($context->config->viewer['route'].'/reload'), ]);}Registering a driver under a name that already exists replaces it — return 'scalar' from
name() and every document already on the default gets your page, no config change needed.
To serve your own script from the gated asset route, implement ViewerAssets as well:
use Docuccino\Core\Extensions\Contracts\ViewerAssets;
class HouseStyleViewer implements Viewer, ViewerAssets{ /** @return array<string, string> */ public function assets(): array { return ['house-style' => resource_path('js/house-style.js')]; }}That map is the allow-list: the file lands at /docs/api/assets/house-style.js, behind the same gate
and middleware as the page, and no other path on that route resolves to anything.
If your driver’s pinned build doesn’t implement the newest OpenAPI minor, implement
ViewerSpecVersion and return the one it does — '3.0', '3.1' or '3.2'. The spec endpoint then
downlevels to that version for your driver alone, and docuccino:cache stores the payload under it,
so switching drivers is a cache miss rather than the previous driver’s version served on. Tolerating
a newer minor is not implementing it: a build that parses 3.2 by treating it as 3.1 drops the newer
minor’s semantics without saying so, so it should declare '3.1'. Declare nothing and your driver is
served the newest format Docuccino emits.