Skip to content

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.

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.

The viewer is available automatically in your local environment. Anywhere else, it’s closed until you name a gate:

  1. Name a gate ability on the document:

    // config/docuccino.php — documents.default.viewer
    'gate' => 'viewApiDocs',
  2. 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.

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.

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.

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.

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.

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 jsDelivr

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

Run docuccino:watch beside php artisan serve and the page you have open refreshes itself whenever a rebuild changes the document:

Terminal window
php artisan docuccino:watch

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

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.