tsitsishvili/documentator
Documentator generates interactive API docs for Laravel by inferring endpoints from routes, FormRequests, Resources and return types, with PHP attributes for overrides. Ships an OpenAPI 3.2 spec plus a built-in explorer UI with a live request playground.
Install and look at the live output before touching config — Documentator is designed to be useful with zero setup on a standard api/* surface.
composer require tsitsishvili/documentator
# Optional: only needed once you want to change matching, auth, grouping, etc.
php artisan vendor:publish --tag=documentator-config
Requirements are PHP 8.2+ and Laravel 12/13. Enable the docs surface explicitly in non-local environments (DOCUMENTATOR_ENABLED=true) rather than relying on defaults.
Then, in order:
/docs — the built-in Aurora explorer. This is the fastest way to see what the package actually inferred./docs/openapi.json — the real artifact. The UI is a view; the JSON is the contract. Judge everything by this file.config/documentator.php — specifically routes.match, routes.exclude, routes.exclude_middleware, security, auth_middleware, grouping, and status-inference settings.php artisan documentator:explain GET /api/whatever on one endpoint you already know well. It shows inference/override provenance, which is the single most useful debugging tool in the package.First use case: pick one existing, conventionally-written endpoint (FormRequest in, Resource out) and do nothing to it. Open /docs, confirm the summary came from the controller docblock, body params came from rules(), the 200/201 schema came from the Resource, and auth came from middleware. Once that "free" case is correct, you know where the inference boundary is and when you'll need attributes.
The package rewards idiomatic Laravel. The canonical endpoint needs no attributes at all:
/**
* Create an order.
*
* Charges the customer and returns the created order.
*/
public function store(StoreOrderRequest $request): OrderResource
{
return new OrderResource(Order::create($request->validated()));
}
Docblock first line → summary, remainder → description. StoreOrderRequest::rules() → body params with types, requiredness, enums, formats, bounds, nested wildcards, uploads. OrderResource → success schema. POST → 201 by convention.
This is the discipline that keeps generated docs maintainable:
| Fact | Preferred source |
|---|---|
| Method + URI | Laravel route |
| Summary / description | Method or closure docblock |
| Path input | Route placeholder, constraint, or model binding |
| Query input | Request accessor ($request->integer('page')), GET/HEAD validation, Query Builder, #[QueryParam] |
| Body | FormRequest, inline validation, Spatie Data input, #[BodyParam] |
| Success schema | Return type/expression, Resource/Data/model, #[Response] |
| Auth | Middleware/config, global auth, #[Authenticated] |
| Group/version | Controller inference/config, #[Group] |
| Visibility | #[Hidden], #[Deprecated] |
Attributes are last-write-wins overrides, not a parallel documentation system. If you find yourself adding #[BodyParam] for a field that's already in the FormRequest, delete one of them.
Route::match(['QUERY'], '/orders/search', ...). The FormRequest rules become request content, and the operation lands under OpenAPI 3.2's query Path Item field. Keep URI query params (cursors, locale) separate from the body criteria.file/image rules flip to multipart; otherwise use #[RequestMediaType].Don't convert a mutating POST into QUERY just to get body support — QUERY is safe/idempotent by contract, and reviewers will notice.
If you already use Spatie packages, the docs come free and there is no runtime dependency:
Optional/Lazy requiredness.allowedFilters, allowedSorts, defaultSort, allowedIncludes, allowedFields become query params, including custom *QueryBuilder subclasses.rules() and handle() return types are read for routes pointing at asController().The catch: these are source-scanned. Building allowedFilters from a variable or a config array defeats the inference.
Reach for #[Response] when a return type is opaque, #[SchemaName]/#[UsesModel] when naming conventions collide, #[Group(..., version: 'v2')] for versioned surfaces, #[Callback]/#[Webhook] for event-driven flows, and #[Response(stream: true, mediaType: 'text/event-stream')] for NDJSON/SSE endpoints (the declared schema is one item, emitted as itemSchema).
Class-level #[Group], #[Authenticated], #[Hidden], #[Deprecated] set defaults across a controller — use these instead of repeating attributes on every method.
Inline PHPDoc is the nicest feature for messy validation arrays:
$request->validate([
/** @var uuid @example 9f40d932-... @default 9f40d932-... */
'user\.uuid' => ['required', Rule::exists('users', 'uuid')],
/** @ignoreParam */
'internal_only' => ['string'],
]);
Treat the OpenAPI document as a versioned artifact:
php artisan documentator:check --against=openapi.json --fail-on=breaking
php artisan documentator:export openapi.json
php artisan documentator:typescript api-client.ts
php artisan documentator:postman
And validate responses in feature tests rather than trusting the document:
$this->getJson('/api/orders/42')->assertMatchesDocumentation();
recordAsDocumentationExample('order found') records a real, redacted example from an existing test — it validates first and never dispatches an endpoint by itself.
When you have api/* and app/*, configure grouping.sections and each surface gets its own UI and split spec (/docs/api, /docs/api/openapi.json). This is often the difference between "one giant unusable spec" and something partners can actually navigate.
Silent omission is the default failure mode. An endpoint can just not appear. Work the checklist in order: php artisan route:list → routes.match → routes.exclude / routes.exclude_middleware → #[Hidden] → confirm the action is an introspectable controller method or closure. Closures with poor typing, Route::resource magic, or actions built by a package may be unreadable — documentator:check flags these; move them to a real controller.
Inference is static, not runtime. Anything dynamic is invisible: rules assembled in a loop, Rule::when with computed conditions, filters built from config, validators constructed in a service. Keep rules literal where you want them documented, or add an explicit attribute.
documentator:check is not a linter for everything. It audits action introspectability, success schemas, health warnings, and OpenAPI conformance, and optionally compares drift. It will not catch "your per_page accessor has no bounds" — you still need to read /docs/openapi.json for the changed operations.
Status conventions will surprise you. POST → 201, DELETE → 204, everything else 200. If your POST legitimately returns 200, or your DELETE returns a body, override with #[Response]. Also remember distinct response shapes sharing a status collapse into oneOf — that's usually right, occasionally not.
Parameter-in-the-wrong-place is almost always a method-semantics bug. GET/HEAD validation goes to query, QUERY validation goes to content. If a body shows up on a GET, look at how the FormRequest is type-hinted and whether the route is actually GET.
Escaped dots and wildcards. Validation keys split on unescaped dots (items.*.id → nested), and user\.uuid is preserved as a literal property name. Get this wrong and you'll ship a schema with a phantom user object.
Auth can over- or under-apply. auth:<guard> maps to the scheme whose key matches the guard, falling back to default. Map custom middleware aliases via documentator.auth_middleware. documentator.authenticate => true emits a top-level security requirement for every operation — endpoints not behind auth opt out automatically, but verify that on the JSON, because a wrongly global auth requirement breaks every external consumer at once.
Schema name collisions. Two Resources called OrderResource in different namespaces will fight over components.schemas. Pin them with #[SchemaName('PublicOrder')]. Similarly, model resolution for Resources is by naming convention (models_namespace) — use #[UsesModel(Order::class)] when the convention misses, or field types will silently degrade to string.
Never ship 3.2 blindly to a 3.1-only toolchain. documentator:export openapi.json --openapi=3.1 produces a projection for downstream consumers. Unsupported QUERY or streaming constructs are reported as actionable errors rather than silently rewritten — read those reports, don't suppress them.
Try-it cross-origin calls need CORS. The explorer runs from the docs origin; if your API doesn't allow that origin, the playground fails while the spec looks perfect. Easy to mistake for a Documentator bug.
Regenerate on deploy. php artisan documentator:generate builds the cached document. If you commit an exported spec, make regeneration part of the pipeline, otherwise the committed contract rots the moment a FormRequest changes.
Protect the docs surface. /docs is public by default once enabled. Put route middleware on it and/or Documentator::auth(...), and consider documentator:check --suggest-hidden to flag operational/internal routes that shouldn't be in a partner-facing spec.
Maturity caveat. At 17 stars with no dependents, this is a young package. Pin the version in composer.json, keep a committed OpenAPI export as the source of truth for consumers, and exercise assertMatchesDocumentation() in tests so an upstream inference change shows up as a test failure rather than a partner complaint. The upgrade guide (1.x → 2.0, OpenAPI 3.2) is worth reading before bumping constraints — the 3.2 requirement is a real toolchain commitment, not a cosmetic one.
How can I help you explore Laravel packages today?