lvntr / api-dock
Laravel API documentation and AI export tooling built on dedoc/scramble (MIT).
Requires
- php: ^8.3
- dedoc/scramble: ^0.13
- guzzlehttp/guzzle: ^7.8|^8.0
- illuminate/contracts: ^12.0|^13.0
- spatie/laravel-package-tools: ^1.16
Requires (Dev)
- larastan/larastan: ^3.0
- laravel/pint: ^1.30
- orchestra/testbench: ^11.0
- pestphp/pest: ^5.0
README
API Dock turns a Laravel API into documentation that both people and models can read. It builds on
dedoc/scramble and does not replace it: Scramble keeps deriving the
OpenAPI document from your application code, while API Dock adds the reading surface on top of it.
Install it, visit /api-dock, and the API you already have is browsable.
What you get
- A documentation browser — a Vue 3 single-page app served from your own application: endpoint sidebar grouped by tag with search, parameter and request-body detail, expandable response and schema trees, a light and a dark theme, and an English and Turkish interface.
- Try it, through your server — the reader sends the request they are looking at, and the package proxies it: host allowlist, DNS-rebinding-safe address pinning, response size cap. Credentials stay server-side, encrypted and scoped to the session; the browser only ever holds a masked hint. Disabled until you enable it.
- An AI prompt tab — one copy button hands a model the whole operation as context, plus the MCP tool
definition and the
llms.txtsection for that endpoint on their own. - A spec diff tab — paste the output of
php artisan api-dock:diff --jsonand read what changed between two versions of the contract, marked breaking, additive or cosmetic. - Six PHP attributes — hints, pitfalls, examples, changelog entries, MCP tool control and feature facts (auth, scopes, rate limit, deprecation, stability), authored next to the controller and emitted into the OpenAPI document as vendor extensions.
- Artisan exports —
llms.txtandmcp-tools.jsonfor agent tooling, plus OpenAPI snapshots and diffs you can run in CI.
Requirements
- PHP
^8.3 - Laravel components
^12.0or^13.0 dedoc/scramble^0.13- The cURL PHP extension when the try-it proxy is enabled; the proxy fails closed without it because it cannot pin a checked DNS address otherwise
Installation
The package is not published on Packagist. Point Composer at the repository, then require the package by name:
{
"repositories": [
{
"type": "vcs",
"url": "https://github.com/lvntrdev/laravel-api-dock"
}
]
}
composer require lvntr/api-dock:~0.0.1
Composer reads the tags of that repository, so a constraint resolves to a released tag exactly as it would through Packagist. The Composer package name stays lvntr/api-dock even though the repository is named laravel-api-dock. A caret constraint on a 0.0.x version is exact in Composer — ^0.0.1 allows only 0.0.1 — so this uses ~0.0.1 instead, which accepts every later 0.0.x release, matching what a plain composer update needs while the package is pre-0.1.
Laravel discovers LvntR\ApiDock\ApiDockServiceProvider through the package manifest. Publish only what the application needs:
php artisan vendor:publish --tag=api-dock-config php artisan vendor:publish --tag=api-dock-assets php artisan vendor:publish --tag=api-dock-views
The api-dock-config tag writes config/api-dock.php. The api-dock-assets tag copies the built SPA files to public/vendor/api-dock. The api-dock-views tag writes the overridable view to resources/views/vendor/api-dock.
The service provider registers the Scramble API under the name api-dock. Its route filter includes application routes whose URI starts with api/. API Dock then uses that named generator for the spec route, snapshots, diffs, and exports.
With the default route_prefix, the package registers these routes:
| Method | Route | Name | Purpose |
|---|---|---|---|
GET |
/api-dock |
api-dock.docs |
Documentation SPA |
GET |
/api-dock/spec |
api-dock.spec |
Generated OpenAPI JSON |
POST |
/api-dock/try-it |
api-dock.try-it |
Proxied outbound request |
GET |
/api-dock/try-it/profiles |
api-dock.try-it.profiles.index |
List masked session profiles |
POST |
/api-dock/try-it/profiles |
api-dock.try-it.profiles.store |
Store a session credential |
DELETE |
/api-dock/try-it/profiles/{profile} |
api-dock.try-it.profiles.destroy |
Remove a session profile |
All paths change with route_prefix. Every route receives the configured middleware followed by API Dock's own enabled check. The try-it and profile routes also receive the configured throttle.
Updating
composer update lvntr/api-dock
That single command is enough. The compiled panel in public/vendor/api-dock is republished automatically:
the package registers that directory under Laravel's laravel-assets tag, and Laravel's own
post-autoload-dump script runs vendor:publish --tag=laravel-assets --force after every Composer command.
Nothing to remember.
If an application removed that script from its composer.json, republish by hand instead:
php artisan vendor:publish --tag=api-dock-assets --force
The asset URL is fingerprinted with the file's own modification time, so a republished file reaches the browser immediately — no cache busting of your own is needed.
Composer caches the repository's tag list per project. When a freshly released version stays invisible, run
composer clear-cache and update again.
If you published the config file, composer update does not touch it. New top-level keys fall back to the
package defaults automatically, but a key added inside an existing group — anything under try_it, for
example — does not, because the merge is one level deep. After a release that adds configuration, compare your
config/api-dock.php with vendor/lvntr/api-dock/config/api-dock.php and copy over what is missing. The same
applies to a view you overrode under resources/views/vendor/api-dock: it keeps its old markup, including the
asset tags, until you reconcile it yourself.
Until version 1.0 a minor bump may change behaviour. Read the release notes for the tag you are moving to before rolling it out.
Before you deploy
- No authentication ships on the documentation routes. The only gate is
api-dock.enabled, and the default middleware stack is['web']. The generated document exposes your internal API surface, so put the routes behind your own auth middleware — themiddlewareconfig key in Configuration reference — before deploying anywhere public. - The try-it proxy is off by default. Turn it on deliberately, keep
try_it.allowed_hostsandtry_it.allowed_methodsas narrow as the job needs, and read Try-it security contract first. - Session credentials are stored in the configured cache, encrypted with the application encrypter and scoped
to the session, and expire after
try_it.ttlof inactivity. They are not kept in the browser. - MCP,
llms.txtand diff artifacts are produced by Artisan, not by HTTP endpoints — nothing extra is exposed on the route table. - A non-text upstream response is not proxied back verbatim. A body that is not valid UTF-8 is replaced with a
short placeholder and the proxy response carries
binary: true, because the panel's JSON transport cannot carry raw bytes.
Configuration reference
These are all keys shipped by config/api-dock.php.
| Key | Default | Effect |
|---|---|---|
enabled |
true |
Enables the API Dock HTTP surface. When false, API Dock routes return 404. |
route_prefix |
'api-dock' |
Prefix applied to every package route. |
middleware |
['web'] |
Host middleware applied before API Dock's access check. Use this to restrict the documentation and proxy surface. |
ai.export_path |
storage_path('api-dock') |
Default directory for generated AI and OpenAPI export files. |
ai.include_examples |
true |
Includes x-ai-examples sections in llms.txt. It does not remove examples from the OpenAPI document. |
ai.mcp_opt_in |
false |
When false, all operations except those with AiTool(enabled: false) become MCP tools. When true, only operations with AiTool(enabled: true) are exported. |
snapshot.path |
storage_path('api-dock/openapi.json') |
Stored OpenAPI snapshot read by sync and diff. |
try_it.enabled |
false |
Enables outbound try-it requests and credential-profile endpoints. |
try_it.allowed_hosts |
[] |
Host allowlist. Empty denies all hosts. A bare entry is exact; a leading-dot entry matches subdomains. |
try_it.self_hosts |
[] |
Additional domains served by this application. Each entry and its subdomains bypass the foreign-host safety gates; the host from APP_URL is already included. |
try_it.timeout |
10 |
Maximum outbound request duration in seconds. Non-positive or non-numeric values fall back to 10. |
try_it.connect_timeout |
5 |
Maximum connection-establishment duration in seconds. Non-positive or non-numeric values fall back to 5. |
try_it.max_response_bytes |
262144 |
Maximum proxied response body, 256 KiB by default. Excess content is truncated and reported with truncated: true. |
try_it.throttle |
'30,1' |
Laravel throttle parameters (requests,minutes) applied to the proxy and profile routes. |
try_it.allowed_methods |
['GET', 'HEAD', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'] |
Methods accepted from the panel. Configuration can narrow this fixed supported set, not expand it. |
try_it.ttl |
3600 |
Idle lifetime in seconds for session credential profiles: every read and every write pushes the expiry out by this much, so the clock measures inactivity rather than the age of the credential. There is no absolute ceiling above it. Non-positive or non-numeric values fall back to 3600. |
try_it.max_profiles |
10 |
Credential profiles kept per session; the oldest is dropped past this. Every read and write refreshes the bucket lifetime, so an uncapped bucket would never expire. |
include_generation_timestamp |
false |
Stamp the generation time into the document. Off by default: it turns every regeneration into a diff. |
For allowed_hosts, a bare entry is an exact host name, and a leading dot covers the site and its subdomains: .example.com matches both example.com and api.example.com. A near miss never matches either form — evil-example.com and example.com.attacker.test are both denied.
Authoring AI metadata as one operation contract
This section is the mechanics. The editorial contract — which fact belongs in which
attribute, what a finished operation looks like, and how to verify one — is
docs/ai-metadata-authoring.md, written to be handed
to a coding assistant verbatim when you ask it to document an endpoint.
All six attributes target controller classes and controller methods. Put shared guidance on the class and operation-specific guidance on the method.
AiExample, AiPitfall, and AiChangelog are repeatable. AiHint, AiTool, and ApiFeature are not repeatable on the same target. Their exact constructors are:
new AiHint(string $hint) new AiPitfall(string $text, int $order = 0) new AiChangelog(string $date, string $summary, bool $breaking = false) new AiExample(string $name, array $request = [], array $response = []) new AiTool( bool $enabled = true, ?string $name = null, ?string $description = null, ) new ApiFeature( ?string $auth = null, ?array $scopes = null, ?int $rateLimit = null, ?string $rateLimitPer = null, ?bool $deprecated = null, ?string $stability = null, )
Class metadata is read before method metadata. Examples are emitted class first and then method. Pitfalls are collected in that order and then stably sorted by order. Changelog entries are collected in that order and then sorted newest first when date is a valid Y-m-d; malformed dates remain visible at the end. For AiHint and AiTool, a method instance replaces the class instance. ApiFeature begins with facts derived from route middleware, applies the class instance field by field, and then applies the method instance field by field; a null field leaves the earlier value unchanged.
Here is one endpoint using the attributes as a single integration contract:
<?php declare(strict_types=1); namespace App\Http\Controllers; use LvntR\ApiDock\Attributes\AiChangelog; use LvntR\ApiDock\Attributes\AiExample; use LvntR\ApiDock\Attributes\AiHint; use LvntR\ApiDock\Attributes\AiPitfall; use LvntR\ApiDock\Attributes\AiTool; use LvntR\ApiDock\Attributes\ApiFeature; #[AiPitfall('Resource IDs are case-sensitive.', order: 10)] #[AiTool(name: 'inspect_resource', description: 'Inspect one API resource.')] #[ApiFeature( auth: 'sanctum', scopes: ['resources:read'], rateLimit: 60, rateLimitPer: 'minute', stability: 'stable', )] final class ResourceController { /** Inspect a resource. */ #[AiHint('Use the returned ETag for the next conditional request.')] #[AiPitfall('A missing resource returns 404, not an empty object.', order: 20)] #[AiExample( name: 'Ready resource', request: ['id' => 'res_42'], response: ['id' => 'res_42', 'status' => 'ready'], )] #[AiChangelog('2026-08-20', 'Added the status field to the response.')] #[ApiFeature(deprecated: false)] public function show(string $id): array { return ['id' => $id, 'status' => 'ready']; } }
Assuming the route is GET /api/resources/{id}, Scramble supplies the ordinary OpenAPI operation and API Dock adds this vendor-extension block:
{
"x-ai-hint": "Use the returned ETag for the next conditional request.",
"x-ai-pitfalls": [
{"order": 10, "text": "Resource IDs are case-sensitive."},
{"order": 20, "text": "A missing resource returns 404, not an empty object."}
],
"x-ai-examples": [
{
"name": "Ready resource",
"request": {"id": "res_42"},
"response": {"id": "res_42", "status": "ready"}
}
],
"x-ai-tool": {
"enabled": true,
"name": "inspect_resource",
"description": "Inspect one API resource."
},
"x-api-dock-changelog": [
{
"date": "2026-08-20",
"summary": "Added the status field to the response.",
"breaking": false
}
],
"x-api-dock-features": {
"auth": "sanctum",
"scopes": ["resources:read"],
"rate_limit": {"limit": 60, "per": "minute"},
"deprecated": false,
"stability": "stable"
}
}
For an OpenAPI path parameter named id with schema { "type": "string" }, the MCP exporter produces this tool definition. An explicit AiTool description takes precedence over the operation summary and hint; pitfalls are appended to it.
{
"name": "inspect_resource",
"description": "Inspect one API resource.\n\nPitfalls:\n1. Resource IDs are case-sensitive.\n2. A missing resource returns 404, not an empty object.",
"inputSchema": {
"type": "object",
"properties": {
"id": {"type": "string"}
},
"required": ["id"]
}
}
The corresponding llms.txt operation contains the Scramble summary, AI hint, ordered pitfalls, authentication line, parameters, request body, responses, examples when enabled, and human changelog. A shortened excerpt is:
### GET /api/resources/{id}
Inspect a resource.
**AI hint:** Use the returned ETag for the next conditional request.
#### Pitfalls
1. Resource IDs are case-sensitive.
2. A missing resource returns 404, not an empty object.
**Authentication:** Required (sanctum)
#### Examples
##### Ready resource
...
#### Changelog
- 2026-08-20 — Added the status field to the response.
x-api-dock-features.auth carries the authentication driver name (sanctum) rather than a boolean; the exporter renders any non-null value as required and names the driver in parentheses. An operation with no guard renders **Authentication:** Not required.
These values are also the ingredients of an operation's agent prompt block: current summary and schema, AiHint, ordered AiPitfall entries, examples, feature requirements, and the human changelog. The Vue AiPanel composes exactly that block and offers it for copying; api-dock:export produces the same content as MCP tool definitions and llms.txt for consumption outside the browser.
The snapshot diff and AiChangelog deliberately serve different readers. The snapshot diff is the machine-generated delta between OpenAPI versions. AiChangelog is a hand-maintained record for the human integrating against the API. Keep both; one does not replace the other.
Console commands
Teach this project's coding agents the authoring rules
api-dock:agent-guide
--file= Instruction file to write, relative to the project root (repeatable)
--print Write the block to output instead of to a file
Run this once after installing. It writes a short, marker-delimited block into the
instruction files coding agents already read on their own — AGENTS.md, and
CLAUDE.md or GEMINI.md when the project keeps them — pointing at
docs/ai-metadata-authoring.md inside vendor/.
From then on "document this endpoint" carries the rules with it and nobody has to
restate them per session. Re-running replaces the block in place rather than adding
a second copy, so it is safe after every upgrade. Nothing outside the markers is
touched, and no file the project has not adopted is created.
Store or check a snapshot
api-dock:sync
--check Exit with code 1 on breaking changes and do not write the snapshot
Without --check, the command compares the generated document with snapshot.path, prints changes grouped as breaking, additive, and cosmetic, writes the new snapshot, and exits 0. With --check, it never writes the snapshot: it exits 1 when at least one breaking change exists and 0 otherwise.
Use it as a CI gate:
- name: Reject breaking API changes run: php artisan api-dock:sync --check
The committed baseline at snapshot.path must already be available in the CI checkout for this comparison to be meaningful.
Inspect a diff
api-dock:diff
--json Emit the structured diff as JSON
The command compares the generated document with the stored snapshot and never writes it. Human output is grouped by severity. --json emits one object with this shape:
{
"has_breaking": true,
"changes": [
{
"severity": "breaking",
"path": "/api/resources/{id}",
"operation": "get",
"type": "response_property_removed",
"description": "..."
}
]
}
operation can be null for a document- or component-level change. The command exits 0 even when the result contains breaking changes; use sync --check for gating.
Export artifacts
api-dock:export
--mcp Write MCP tool definitions
--llms Write the llms.txt bundle
--openapi Write the generated OpenAPI document
--output= Override the export directory
Select one or more format flags in the same invocation. Files are named mcp-tools.json, llms.txt, and openapi.json. Without --output=, they are written under ai.export_path. The command exits 0 on success and 1 when no format is selected or an export fails.
php artisan api-dock:export --mcp --llms --openapi php artisan api-dock:export --mcp --output=storage/app/agent-contracts
Try-it security contract
The try-it proxy is disabled by default. Setting api-dock.try_it.enabled to true is a deliberate operator decision.
This application's own host needs no allowlist entry. The self host comes from the host in APP_URL (config('app.url')), and any subdomain of it is covered automatically. If this application answers on any other domain, list that bare hostname in try_it.self_hosts. A subdomain of a self_hosts entry also counts as self, but its parent domain does not. Entries are lowercased and trimmed, with a trailing dot removed; empty entries, malformed hostnames, leading-dot forms such as .example.com, and address literals in any spelling (127.0.0.1, 127.1, 2130706433, 0x7f000001) are ignored.
A self host bypasses try_it.allowed_hosts, the internal-service host list, and the private-address check after DNS resolution. Use self_hosts only for domains of this application — never for a foreign host or an internal service name. Use allowed_hosts for foreign hosts.
Upgrade note: the proxy no longer trusts the incoming Host header when deciding what is self. After upgrading, a deployment whose APP_URL does not match the domain it is served on receives a 422 when trying its own API until APP_URL is corrected or that served domain is added to try_it.self_hosts.
The host allowlist governs foreign hosts only, and is deny-by-default: an empty allowed_hosts denies every one of them. Bare entries are exact host names. A leading dot, such as .example.com, accepts that site and its subdomains — both example.com and api.example.com — the way a cookie domain does. It never accepts a near miss: evil-example.com and example.com.attacker.test are denied by both forms.
Only HTTP and HTTPS URLs are accepted. URLs carrying authority credentials, malformed hosts, conventionally internal host names, and internal DNS suffixes are rejected. After DNS resolution, every address is checked against private, loopback, link-local, shared, unspecified, documentation, benchmark, multicast, reserved, and cloud-metadata-relevant IPv4 and IPv6 ranges. Every resolved address must pass. The checked addresses are pinned with cURL for the actual connection, closing the DNS-rebinding window between validation and connection. Redirects are not followed.
Credentials are scoped to the current Laravel session. They are encrypted with the application's encrypter before being stored in the cache and expire after try_it.ttl of inactivity: listing a profile, looking one up, or sending a request with it pushes the expiry out by another full try_it.ttl, so credentials survive an active working session, while an abandoned one still expires. A read never revives an already-expired profile, and there is no absolute cap above the idle window, so pick try_it.ttl deliberately. A profile also carries server_variables, the values substituted into a server template; that map is ordinary non-secret data and is returned in the clear like base_url, so a credential must never be put in it. Profile list and lookup responses omit the ciphertext and plaintext; they return only profile metadata and credential_hint, which is **** for credentials of eight characters or fewer and **** plus the last four characters otherwise. The outbound request path is the only code path that decrypts a credential. Reads never return a usable credential to the browser, so the panel does not regain one after profile creation. The browser keeps only the selected profile id, the server variable values, and the plain base URL under the api-dock:try-it key in localStorage, so a page reload does not reset the panel. That store never holds a credential, a credential header value, or a credential_hint, but it does outlive the session and is shared by every specification on the origin — on a shared browser, clear site data when you are done.
Response bodies are capped at try_it.max_response_bytes. Content beyond the cap is discarded, the returned body is truncated to the cap, and the response carries truncated: true; the proxy does not buffer an unbounded body. Hop-by-hop headers, cookies, forwarding headers, CSRF headers, proxy-prefixed headers, and browser Sec-* headers are stripped in both directions.
Enabling this feature means your Laravel server will make outbound HTTP requests to allowlisted hosts on behalf of anyone who can reach the documentation route. Leave it disabled in production unless that behavior is required. When it is required, restrict the documentation surface itself with middleware, for example with the application's authentication and authorization middleware, and keep the host and method lists as narrow as possible.
Try-it server variables
OpenAPI servers[].variables are intended to reach the panel as one control per variable: an enum can be presented as a select and other variables as text inputs, with the declared default prefilled. The client sends the untouched server template, supplied values, and variable enum/default specifications to the backend. Substitution happens server-side. The client makes no allow or deny decision.
The server rejects a variable value containing any of /, \, @, :, ?, #, [, ], whitespace, or ASCII control characters. It repeats percent-decoding before checking, so encoded separators such as %2F and nested forms such as %252F are also rejected. Values encoded too deeply to validate within five passes are rejected. It also rejects a value outside a non-empty declared enum, a variable with neither a supplied non-empty value nor a default, and a template that still contains an unsubstituted {placeholder}. Accepted values are inserted with rawurlencode; the final URL still passes the full scheme, host, allowlist, DNS, and address-range checks.
AI integration
Generate artifacts with php artisan api-dock:export --mcp --llms. Give llms.txt to a model as API context when it needs a readable operation catalogue; it groups operations by their first tag and includes parameters, JSON request and response schemas, hints, pitfalls, optional examples, and changelog entries. Give mcp-tools.json to an MCP host that accepts tool definitions with name, description, and JSON Schema inputSchema fields.
The MCP inputSchema merges path, query, and header parameters, followed by top-level properties from an application/json request body. Local #/components/... parameter and schema references are resolved. Path parameters are always required; other parameter and body requirements follow OpenAPI. When names collide, the first property keeps its name and the later property receives a deterministic source prefix: query_id, header_id, or body_id. A further collision appends _2, then _3, and so on. The renamed property's description records its source.
Tool names use the first available value: AiTool::$name, OpenAPI operationId, or a lower-case method_path fallback. AiTool(enabled: false) always excludes an operation. With ai.mcp_opt_in enabled, an operation must explicitly carry AiTool(enabled: true).
| Extension | Location and shape | Meaning |
|---|---|---|
x-ai-hint |
Operation: string |
Current model-facing guidance. A method hint replaces a class hint. |
x-ai-pitfalls |
Operation: list of {order: int, text: string} |
Ordered integration hazards. MCP appends their text to the tool description; llms.txt renders a numbered section. |
x-ai-examples |
Operation: list of {name: string, request: object/array, response: object/array} |
Named request/response pairs. ai.include_examples controls their inclusion in llms.txt. |
x-ai-tool |
Operation: `{enabled: bool, name: string | null, description: string |
x-api-dock-features |
Operation: `{auth: string | null, scopes: string[], rate_limit: {limit: int, per: string} |
x-api-dock-changelog |
Operation: list of {date: string, summary: string, breaking: bool} |
Human-maintained integration history, newest valid date first. |
x-api-dock |
Document: {version: string} |
API Dock package metadata. The current package fallback version is dev. |
The exporters consume the generated OpenAPI document; they do not inspect controller attributes directly. This keeps OpenAPI as the shared contract among the documentation UI, snapshots, MCP tools, and llms.txt.
Attribution and license
API Dock is built on dedoc/scramble, which is distributed under the MIT License. API Dock is also distributed under the MIT License; see LICENSE.
Scramble Pro is not a dependency.
API Dock is written and maintained by Levent Acar — lvntr.dev.