If you’ve been using knuckleswtf/scribe to generate API documentation for a Laravel application, there’s a good chance you’ve hit this wall at some point: the docs page loads slowly, the browser tab spins for several seconds, or the page just becomes unresponsive. The index.html file for a large API can balloon past 10MB, and the browser has to process all of it before showing you anything.
The natural question is: can Scribe split the documentation into multiple pages โ one per group, for instance โ so the browser isn’t loading everything at once?
The short answer is no, not natively. But there’s a proper solution, and it involves rethinking the output type rather than the content structure. This post walks through what’s actually happening, how to fix it, and how to handle both a standard config/scribe.php and a custom config file.
What’s Actually Causing the Hang
When you use Scribe’s static or laravel output type, it takes every endpoint you’ve documented โ request bodies, response examples, parameter tables, code snippets for every configured language โ and renders all of it into HTML. Everything ends up in a single index.html file.
When the browser opens that file, it has to:
- Parse a very large HTML document
- Build the full DOM tree for every endpoint
- Execute the JavaScript that wires up all the interactive elements โ collapsible sections, “Try It Out”, the language switcher
- Layout and paint everything before showing you the page
None of this is deferred or lazy. The browser works through the entire document upfront. On a small API, this is imperceptible. But as endpoint count grows, these costs stack up fast. At a few hundred endpoints you’re looking at 5โ15 seconds. At a few thousand, the tab can crash entirely.
The fix is not to fiddle with the HTML structure. The fix is to stop asking Scribe to render the endpoint HTML at all.
The Solution: external_static or external_laravel with Scalar
Scribe has two lesser-used output types documented in the config reference: external_static and external_laravel. Instead of generating a self-contained rendered HTML document, these types produce:
- Your
openapi.yamlโ the full OpenAPI 3 spec for your API - A minimal HTML shell that loads an external renderer and points it at the spec
The renderer then handles everything: parsing the spec, building the UI, powering search and Try It Out. Critically, modern renderers do this lazily โ they only render what’s in the viewport. Everything else is deferred until the user scrolls to it.
Scribe supports three external renderers via the theme key:
scalarโ best performance, cleanest UI, most actively developedelementsโ Stoplight Elementsrapidoc
This post uses Scalar, but the configuration approach is the same for all three.
A note on valid
themevalues: thethemekey is type-dependent. Forstaticandlaravel, valid values aredefaultandelements. Forexternal_staticandexternal_laravel, valid values arescalar,elements, andrapidoc. Usingdefaultwith anexternal_*type throws aView [external.default] not founderror.
Fixing the Default config/scribe.php
Open config/scribe.php and change two values:
// Before
'type' => 'static',
'theme' => 'default',
// After
'type' => 'external_static',
'theme' => 'scalar',
If you’re using laravel type because you serve docs through your app’s routing (with middleware, auth, etc.), use external_laravel instead:
'type' => 'external_laravel',
'theme' => 'scalar',
Then regenerate:
php artisan scribe:generate
That’s it. For external_static, Scribe places the output in the path specified by static.output_path (default: public/docs). For external_laravel, it’s served at the path set in laravel.docs_url (default: /docs). The generated index.html is now a thin shell. The openapi.yaml carries all the actual API data, and Scalar renders it on demand in the browser.
Fixing a Custom Config File
Scribe supports multiple independent doc sets via separate config files, each targeted with the --config flag:
php artisan scribe:generate --config scribe_admin
The fix is identical โ change type and theme inside config/scribe_admin.php:
'type' => 'external_static', // or 'external_laravel'
'theme' => 'scalar',
Everything else โ your routes array, static.output_path, auth settings, strategies โ stays exactly as it was. Only those two lines change.
One thing worth noting from the docs: if you’re running multiple configs with the laravel type (or external_laravel), the built-in routing (laravel.add_routes) won’t work for your secondary configs. You’ll need to add routes manually in routes/web.php:
Route::view('/docs', 'scribe.index')->name('scribe');
Route::view('/admin/docs', 'scribe_admin.index')->name('scribe-admin');
Customising Scalar
When you switch to an external_* type, Scribe generates an HTML shell using a blade template. Publish it into your project so you can edit it:
php artisan vendor:publish --tag=scribe-views
This creates resources/views/vendor/scribe/external/scalar.blade.php. Open it, find the <script id="api-reference"> tag, and add a data-configuration attribute:
<script
id="api-reference"
data-url="{{ $openApiUrl }}"
data-configuration='{"theme":"moon","layout":"modern","darkMode":true}'
></script>
Because this file is in resources/views/vendor/scribe/, it’s part of your project โ not generated output. Running php artisan scribe:generate again won’t touch it.
Don’t use
external.html_attributesfor Scalar config. There’s a known issue where Scribe’s serialisation of that PHP array into HTML attributes produces malformed JSON, which causes Scalar to throw aSyntaxError: Expected property name or '}' in JSONon load. The published blade template is the reliable path.
Scalar Themes
| Theme | Style |
|---|---|
default | Clean neutral light |
alternate | Warmer variant of default |
moon | Cool dark |
purple | Dark with purple accents |
solarized | Solarized palette |
bluePlanet | Deep blue dark |
saturn | Warm dark |
kepler | High-contrast dark |
mars | Reddish dark |
deepSpace | Very dark, high-contrast |
none | Completely unstyled โ for custom CSS |
All themes support light and dark mode. Use forceDarkModeState to lock one:
{
"theme": "moon",
"forceDarkModeState": "dark",
"hideDarkModeToggle": true
}
Other Useful Scalar Options
{
"layout": "modern",
"hideSearch": false,
"hideModels": false,
"defaultOpenFirstTag": true,
"expandAllResponses": false,
"showOperationId": false,
"hideTestRequestButton": false,
"documentDownloadType": "yaml",
"defaultHttpClient": {
"targetKey": "node",
"clientKey": "axios"
},
"hiddenClients": {
"js": ["jquery", "xhr"],
"shell": ["httpie", "wget"]
},
"metaData": {
"title": "My API Docs",
"description": "API reference"
}
}
layout accepts "modern" (sidebar navigation) or "classic" (Swagger-style). documentDownloadType accepts "json", "yaml", "both", "direct", or "none".
Generating Multiple Separate Doc Sites
If you need genuinely separate documentation sites for different parts of your API, Scribe’s multiple config file approach handles that. Each config file gets its own routes scope and output path:
config/scribe.php โ public/docs/
config/scribe_admin.php โ public/admin/docs/
config/scribe_webhooks.php โ public/webhooks/docs/
Each config has a routes array scoped to only the relevant endpoints:
// config/scribe_admin.php
'routes' => [
[
'match' => [
'prefixes' => ['api/v1/admin/*'],
'domains' => ['*'],
],
],
],
'static' => [
'output_path' => 'public/admin/docs',
],
Generate each independently:
php artisan scribe:generate
php artisan scribe:generate --config scribe_admin
php artisan scribe:generate --config scribe_webhooks
You can wire all of them into a Composer script to run them together:
{
"scripts": {
"docs:generate": [
"php artisan scribe:generate",
"php artisan scribe:generate --config scribe_admin",
"php artisan scribe:generate --config scribe_webhooks"
]
}
}
composer docs:generate
Apply external_static + Scalar to all of them for consistent fast rendering across the board.
Summary
| Output Type | Renders HTML | Grows with Endpoints | Lazy Rendering |
|---|---|---|---|
static | Scribe | Yes | No |
laravel | Scribe | Yes | No |
external_static | Scalar | No | Yes |
external_laravel | Scalar | No | Yes |
If your Scribe docs are hanging in the browser, the fix is two lines in your config:
'type' => 'external_static',
'theme' => 'scalar',
Regenerate, and the browser now loads a lightweight shell in milliseconds. Scalar fetches the openapi.yaml and renders endpoints progressively as you scroll. Your OpenAPI spec stays exactly the same โ the only thing that changed is who processes it.





