AI Metadata for API Endpoints
lvntr/api-dock reads six PHP attributes off your App\Http\Controllers\Api\* classes and methods and emits them as vendor extensions on the generated OpenAPI document. They exist to make an endpoint self-describing to an AI agent (or a human skimming /api-dock) beyond what parameters and response shapes already say: why a field behaves the way it does, what trips people up, a runnable example, whether the operation is exposed as an MCP tool, and a human-written history of behavior changes.
None of this is required. An undecorated controller documents fine through Scramble alone; add attributes only where the base OpenAPI output leaves something unsaid.
The Six Attributes
Source: vendor/lvntr/api-dock/src/Attributes/.
| Attribute | Target | Repeatable | Constructor |
|---|---|---|---|
AiHint |
class or method | no | string $hint |
AiPitfall |
class or method | yes | string $text, int $order = 0 |
AiChangelog |
class or method | yes | string $date, string $summary, bool $breaking = false |
AiExample |
class or method | yes | string $name, array $request = [], array $response = [] |
AiTool |
class or method | no | bool $enabled = true, ?string $name = null, ?string $description = null |
ApiFeature |
class or method | no | ?string $auth = null, ?array $scopes = null, ?int $rateLimit = null, ?string $rateLimitPer = null, ?bool $deprecated = null, ?string $stability = null |
AiHint, AiTool, and ApiFeature are single-value: putting a second one on the same class or method just leaves the extras unread (only the first attribute instance is picked up). AiPitfall, AiChangelog, and AiExample are declared IS_REPEATABLE and are meant to be stacked.
Merge Order
For every operation, AiMetadataOperationExtension and FeatureOperationExtension read class-level attributes first, then method-level attributes, and combine them:
AiHint: method wins outright if present, otherwise the class hint is used. There is no merge — one hint reaches the document.AiPitfall: class pitfalls and method pitfalls are concatenated, then stably sorted byorder(ties keep their original declaration position — class before method).AiChangelog: class and method entries are concatenated, then sorted newest date first; entries with an unparsabledatesort last, ties keep declaration order.AiExample: class examples, then method examples, in that order — no sorting or dedup.AiTool: method wins outright if present, otherwise the class attribute is used (same single-value rule asAiHint).ApiFeature: this one does merge field-by-field rather than picking one instance. Every non-nullfield on a class-levelApiFeatureis applied, then every non-nullfield on a method-level one is applied on top — so a method attribute overrides only the fields it sets and anullfield leaves the earlier value untouched.auth,rateLimit/rateLimitPer,deprecated, andstabilityare also seeded from route middleware (auth,can/ability/permission/scope*,throttle) and the#[Deprecated]attribute before anyApiFeatureoverride is applied.
Emitted Vendor Extensions
Each attribute value lands in the generated OpenAPI operation under an x- prefixed key:
| Extension key | Source attribute(s) |
|---|---|
x-ai-hint |
AiHint |
x-ai-pitfalls |
AiPitfall (sorted array) |
x-ai-examples |
AiExample (array) |
x-ai-tool |
AiTool ({enabled, name, description}) |
x-api-dock-changelog |
AiChangelog (sorted array) |
x-api-dock-features |
ApiFeature + inferred middleware ({auth, scopes, rate_limit, deprecated, stability}) |
Worked Example
Annotating store() on the kit's own App\Http\Controllers\Api\UserController (stubs/app/Http/Controllers/Api/UserController.php), which already returns through the ApiResponse envelope via to_api():
use LvntR\ApiDock\Attributes\AiChangelog;
use LvntR\ApiDock\Attributes\AiExample;
use LvntR\ApiDock\Attributes\AiHint;
use LvntR\ApiDock\Attributes\AiPitfall;
use LvntR\ApiDock\Attributes\ApiFeature;
#[AiHint('Creates an admin-managed user; the caller must already hold users.create.')]
#[ApiFeature(scopes: ['users.create'])]
class UserController extends Controller
{
#[AiPitfall('email must be unique across active AND soft-deleted users — a re-invite reuses the row instead of erroring.', order: 1)]
#[AiPitfall('roles is optional on create; an omitted value leaves the user with no role, not a default one.', order: 2)]
#[AiExample(
name: 'Create a support agent',
request: ['first_name' => 'Ada', 'last_name' => 'Lovelace', 'email' => '[email protected]', 'roles' => ['support']],
response: ['success' => true, 'status' => 201, 'message' => 'User created successfully.'],
)]
#[AiChangelog(date: '2026-08-01', summary: 'roles became optional; previously required.', breaking: true)]
public function store(StoreUserRequest $request, CreateUserAction $action): ApiResponse
{
// ...
}
}
The class-level AiHint and ApiFeature apply to every method on the controller (index, show, update, destroy included) unless a method overrides them; the pitfalls, example, and changelog above are scoped to store() alone.
Export Surface
php artisan api-dock:export writes AI-oriented artifacts built from the same annotated OpenAPI document:
--llms→llms.txt, a plain-text digest of the API meant to be pasted into an LLM context window or served at/llms.txt.--mcp→mcp-tools.json, MCP (Model Context Protocol) tool definitions. Withapi-dock.ai.mcp_opt_inset totrueinconfig/api-dock.php, only operations carrying anAiToolattribute are exported; leftfalse(the default), every operation is eligible.--openapi→ the plain generatedopenapi.json, extensions included.
Artifacts are written to config('api-dock.ai.export_path') (default storage_path('api-dock')).
Separately, php artisan api-dock:sync regenerates the OpenAPI document, diffs it against the stored snapshot at config('api-dock.snapshot.path'), reports breaking / additive / cosmetic changes (api-dock:diff --json for the machine-readable form), and updates the snapshot.
AiChangelog and the snapshot diff are deliberately two different things. AiChangelog is a hand-written attribute aimed at a human integrator reading the docs UI or llms.txt — it says whatever you write in summary, on whatever schedule you remember to write it. The snapshot diff is machine-generated from comparing two OpenAPI documents field-by-field — it catches every schema drift whether or not anyone wrote it down. Use the diff to find out that something changed; use AiChangelog to say why it changed and what to do about it. Neither substitutes for the other.
Security
The try-it proxy inside the /api-dock panel is on by default (config('api-dock.try_it.enabled')), but try_it.allowed_hosts and try_it.self_hosts both ship empty — with them empty the proxy can only reach this application's own host, not an arbitrary foreign one (see stubs/config/api-dock.php for the full SSRF-surface rationale before adding an entry).
The whole /api-dock surface — panel, spec, try-it proxy, and credential-profile endpoints — sits behind ['web', 'auth', CheckApiDocsAccess::class] (config('api-dock.middleware')), which denies unless Gate::allows('viewApiDocs'). The kit wires that gate to the seeded api-docs.read permission ability (config/permission-resources.php), granted to the developer role by default, and fails closed: an unseeded ability denies rather than allowing through.