stacktrace/inspec generates an OpenAPI 3 document from PHP attributes on Laravel controller actions and Fractal transformers.
It is route-aware at generation time: every documented operation is paired with a real Laravel route, so Inspec can infer methods, middleware, and auth from the framework route definition.
composer require stacktrace/inspecThe package revolves around StackTrace\Inspec\Api, StackTrace\Inspec\Documentation, and StackTrace\Inspec\OpenAPIDocument.
use StackTrace\Inspec\Api;
use StackTrace\Inspec\Documentation;
class PublicApiDocumentation extends Documentation
{
public function build(Api $api): void
{
$api
->name('public')
->title('Example API')
->description('Public API documentation')
->version('1.0.0')
->prefix('api')
->servers([
'Production' => 'https://api.example.com',
'Local' => 'http://localhost:8000/api',
])
->controllers(app_path('Http/Controllers/Api'))
->post(
'/webhooks',
tags: 'Webhooks',
summary: 'Receive webhook deliveries',
request: [
'event:string' => 'Webhook event name',
],
response: [
'status:string' => 'Delivery status',
],
);
}
}
$api = new Api();
(new PublicApiDocumentation())->build($api);
$document = $api->toOpenAPI();
$yaml = $document->toYaml();Generation currently works like this:
Documentationclasses configure anApibuilder.Apiscans the configured controller paths for public methods with#[StackTrace\Inspec\Route(...)].- Each annotated method must also be registered as a Laravel route. Unregistered methods are skipped.
Apican also document existing Laravel routes directly with helpers likepost('/webhooks', ...)orroute('webhooks.receive', ...).- Manual route helpers also accept
operation: new \StackTrace\Inspec\Operation(...)when you want to build or customize the route metadata explicitly. - Invokable controllers are supported through
__invoke. - Transformer schemas are collected from
#[Schema(...)]on the transformer'stransform()method. - Call
->prefix('api')when Laravel routes are registered under/apibut you want generated paths like/usersinstead of/api/users. - Path filters always match the final generated path, so with
->prefix('api')you should filter with^/users, not^/api/users.
Configure the documentation classes in config/inspec.php:
return [
'output' => 'openapi',
'docs' => [
App\OpenApi\PublicApiDocumentation::class,
],
];Then generate all configured specs:
php artisan inspec:generateOr generate one configured API by its name():
php artisan inspec:generate --api=publicOr verify a single documentation class without rewriting files:
php artisan inspec:generate --api=App\\OpenApi\\PublicApiDocumentation --stdout
php artisan inspec:generate --api=public --stdout --path='^/users' --method=GET
php artisan inspec:generate --api=public --stdout --route=users.showThe --path option matches the final generated path after any Api::prefix(...) stripping.
Not every route lives in a controller you can annotate. For package routes, closure routes, or third-party endpoints, configure them directly inside build():
<?php
namespace App\OpenApi;
use StackTrace\Inspec\Api;
use StackTrace\Inspec\Documentation;
use StackTrace\Inspec\Operation;
class WebhookDocumentation extends Documentation
{
public function build(Api $api): void
{
$api
->name('webhooks')
->post(
'/webhooks',
tags: 'Webhooks',
summary: 'Receive webhook deliveries',
request: [
'event:string' => 'Webhook event name',
],
response: [
'status:string' => 'Delivery status',
],
)
->route(
'webhooks.named',
tags: 'Webhooks',
summary: 'Named webhook endpoint',
response: [
'status:string' => 'Webhook response status',
],
);
}
}Both helpers resolve a real Laravel route before documenting it. If the route does not exist, or if a method/path match is ambiguous, generation fails.
You can also pass a prebuilt Operation to the helper instead of the long named-argument surface:
$api->post(
'/webhooks',
operation: (new Operation(tags: 'Webhooks'))
->summary('Receive webhook deliveries')
->headers([
'X-Webhook-Signature!:string' => 'Webhook signature',
])
->request([
'event:string' => 'Webhook event name',
])
->response([
'status:string' => 'Delivery status',
]),
);If Laravel registers a real route as /api/webhooks but you want the generated OpenAPI path to be /webhooks, configure ->prefix('api') and still reference the real Laravel URI in the helper:
$api
->prefix('api')
->post('/api/webhooks', ...);Import the route attribute in your controller:
use StackTrace\Inspec\Route;<?php
namespace App\Http\Controllers\Api;
use StackTrace\Inspec\Route;
class ShowStatusController
{
#[Route(
tags: 'Status',
summary: 'Show API status',
response: [
'name:string' => 'Application name',
'version:string' => 'Current API version',
'healthy:boolean' => 'Whether the API is healthy',
],
)]
public function __invoke()
{
//
}
}This produces a single operation tagged with Status, a summary, and a 200 JSON response.
<?php
namespace App\Http\Controllers\Api;
use App\Transformers\UserTransformer;
use StackTrace\Inspec\Route;
class ListAccountUsersController
{
#[Route(
tags: ['Accounts', 'Users'],
summary: 'List account users',
route: [
'account:string' => 'Account UUID',
],
query: [
'search:string' => 'Free-text search term',
'status!:string|enum:active,disabled' => 'Required status filter',
'include?:string' => 'Comma-separated includes',
],
headers: [
'X-Account-Id!:string' => 'Required account identifier',
'X-Client:string|enum:web,mobile' => 'Optional client type',
],
response: [
'data:array' => UserTransformer::class,
],
)]
public function __invoke(string $account)
{
//
}
}Parameter behavior comes from the property DSL:
- Path parameters use
?to determine whether the generated parameter is marked as required. - Query parameters use
!to determine whether the parameter is required. - Request headers also use
!to determine whether the parameter is required. - Query parameter and request header enums are emitted when you add an
|enum:...modifier. - Header names are emitted exactly as authored, without the DSL marker and type suffix.
Define shared request headers once in Documentation::build(Api $api) using the same property DSL:
$api
->headers([
'X-Locale:string|enum:sk,en' => 'Response language',
'X-Tenant-Id!:string' => 'Tenant identifier',
])
->controllers(app_path('Http/Controllers/Api'));Controller operations and manually documented routes inherit these headers, including named routes and explicit Operation objects. Automatically discovered broadcasting operations do not inherit them; their headers can still be configured through the broadcasting callback. Manually documented broadcasting routes follow the manual-route rules.
An endpoint's header definition completely replaces a global definition with the same name, compared case-insensitively:
#[Route(headers: [
'x-locale!:string|enum:en,de' => 'Required language for this endpoint',
])]This replaces the global X-Locale definition, including its name casing, type, required flag, enum, and description. Other global headers remain. Global headers keep their definition order, overrides keep the original position, and new local headers follow them. When a name appears more than once within either list, the last definition wins.
Each headers() call replaces the entire global list; headers([]) clears it. Configuration is specific to the Api instance and is applied at generation time, including to routes registered before the call. Individual endpoints cannot opt out of global headers. Headers are emitted inline in each operation's parameters, without shared $ref definitions.
<?php
namespace App\Http\Controllers\Api;
use App\Transformers\UserTransformer;
use StackTrace\Inspec\Route;
class CreateUserController
{
#[Route(
tags: 'Users',
summary: 'Create a user',
request: [
'name:string' => 'Present in the payload and nullable in the generated schema',
'email!:string' => 'Present and non-nullable',
'nickname?:string' => 'Optional and nullable',
'timezone?!:string' => 'Optional and non-nullable',
'role:string|enum:admin,member' => 'Assigned role',
'profile' => [
'@description' => 'Nested profile payload',
'@example' => [
'bio' => 'Builder and API enthusiast',
],
'bio?:string' => 'Short biography',
],
],
responseCode: 201,
response: [
'data' => UserTransformer::class,
],
additionalResponses: [
401 => 'Unauthenticated',
422 => 'Validation failed',
],
)]
public function __invoke()
{
//
}
}Notes:
responseCodeapplies to the primaryresponse,paginatedResponse, orcursorPaginatedResponse.- When a request body is present, Inspec automatically adds a
422validation response unlessadditionalResponses[422]or API-level error-response configuration overrides it. - When a route uses
throttlemiddleware, Inspec automatically adds a429too-many-requests response unlessadditionalResponses[429]or API-level error-response configuration overrides it. additionalResponsesacceptsnull, plain strings,Responseinstances, andResponseclass strings.- Use
422 => nullor429 => nullto suppress an inferred error for a single route. - Use
Api::withValidationErrorResponse(),Api::withoutValidationErrorResponse(),Api::withTooManyRequestsResponse(), andApi::withoutTooManyRequestsResponse()to configure the API-wide inferred error defaults.
Define shared responses once in Documentation::build(Api $api):
use StackTrace\Inspec\Response;
$api->additionalResponses([
401 => new Response(
description: 'Unauthenticated',
body: ['message!:string' => 'Error message'],
),
403 => new Response(
description: 'Forbidden',
body: ['message!:string' => 'Error message'],
),
500 => 'Internal server error',
]);Global 401 and 403 responses are inherited only by operations that receive bearerAuth: Sanctum integration must be enabled and the route must use auth:<guard> matching withSanctumGuards() (default: sanctum). Calling withoutSanctum() also disables inheritance of these two codes. Other global codes apply to public and authenticated operations alike.
Controller operations and manually documented routes inherit global responses, including named routes and explicit Operation objects. Automatically discovered broadcasting operations do not inherit them; responses set in their customization callback still apply. Manually documented broadcasting routes follow the manual-route rules.
Local additionalResponses take precedence over global definitions, which take precedence over generated primary responses and inferred 422/429 responses. Overrides replace the entire response for that status code. A null value removes the response; a local definition can restore a code removed globally. Explicit local 401 and 403 definitions apply even to public endpoints.
For example, omit a globally configured forbidden response on one endpoint:
#[Route(additionalResponses: [403 => null])]Global definitions accept the same values as local definitions: Response instances, Response class strings, text descriptions, and null. Named responses use components.responses references; unnamed responses are emitted inline. Only final responses remaining after overrides are registered as reusable components.
Each additionalResponses() call replaces the entire global list; additionalResponses([]) clears it. Configuration belongs to the Api instance and is applied at generation time, including to routes registered before the call. Existing inferred-error configuration remains available; explicit global responses can override it.
When using OpenAPIDocument directly, call its additionalResponses() setter before adding routes. Its route() method accepts inheritAdditionalResponses: false to exclude a route from inheritance.
response: [...] still defines the inner success payload for normal non-paginated operations.
Use Api::withSuccessResponse() when that payload should be wrapped or when the default success description, content type, or headers should change API-wide.
<?php
namespace App\OpenApi;
use StackTrace\Inspec\Api;
use StackTrace\Inspec\Documentation;
use StackTrace\Inspec\SuccessResponse;
class WrappedSuccessResponse extends SuccessResponse
{
protected static function defaultDescription(): string
{
return 'Successful response';
}
protected static function defaultContentType(): string
{
return 'application/vnd.api+json';
}
protected function buildBody(array $schema): ?array
{
return [
'type' => 'object',
'properties' => [
'data' => $schema,
'meta' => [
'type' => 'object',
'properties' => [
'wrapped' => [
'type' => 'boolean',
],
],
],
],
];
}
}
class PublicApiDocumentation extends Documentation
{
public function build(Api $api): void
{
$api
->name('public')
->withSuccessResponse(
(new WrappedSuccessResponse())->withHeaders([
'x-trace-id:string' => 'Trace identifier',
]),
);
}
}Use transformer class strings for paginated responses.
Paginator controls the full pagination behavior: query parameters, paginator schema, meta block, and the final success response.
<?php
namespace App\OpenApi;
use StackTrace\Inspec\Api;
use StackTrace\Inspec\Paginators\CursorPaginator;
use StackTrace\Inspec\Documentation;
use StackTrace\Inspec\Paginators\LengthAwarePaginator;
class WrappedLengthAwarePaginator extends LengthAwarePaginator
{
protected static function defaultResponseDescription(): string
{
return 'Successful response';
}
protected function buildResponseBody(array $items, array $metaProperties): ?array
{
return [
'type' => 'object',
'properties' => [
'results' => [
'type' => 'array',
'items' => $items,
],
'page' => [
'type' => 'object',
'properties' => $metaProperties,
],
],
];
}
}
class PublicApiDocumentation extends Documentation
{
public function build(Api $api): void
{
$api
->name('public')
->prefix('api')
->withPagination(
(new WrappedLengthAwarePaginator())
->withMeta([
'filters' => [
'status?:string' => 'Applied status filter',
],
])
->defaultPerPage(50)
)
->withCursorPagination(
(new CursorPaginator())
->withMeta([
'trace_id:string' => 'Cursor trace identifier',
])
->defaultPerPage(100)
->withResponseDescription('Cursor response')
);
}
}<?php
namespace App\Http\Controllers\Api;
use App\Transformers\UserTransformer;
use StackTrace\Inspec\Route;
class ListUsersController
{
#[Route(
tags: 'Users',
summary: 'List users',
paginatedResponse: UserTransformer::class,
)]
public function __invoke()
{
//
}
}<?php
namespace App\Http\Controllers\Api;
use App\Transformers\UserTransformer;
use StackTrace\Inspec\Route;
class CursorUsersController
{
#[Route(
tags: 'Users',
summary: 'List users with cursor pagination',
cursorPaginatedResponse: UserTransformer::class,
)]
public function __invoke()
{
//
}
}Pagination behavior:
paginatedResponseuses the activeLengthAwarePaginatordefinition and adds its query parameters plus a paginatedmetablock.cursorPaginatedResponseuses the activeCursorPaginatordefinition and adds its query parameters plus a cursormetablock.Api::withPagination()andApi::withCursorPagination()replace the API-wide paginator defaults.- Custom paginator subclasses may change the success envelope by overriding
buildResponseBody(...). Paginator::withResponseDescription(),withResponseContentType(), andwithResponseHeaders()customize the generated success response metadata.- The built-in defaults still use
limit+pagewithmeta.pagination, andlimit+cursorwithmeta.cursor.
<?php
namespace App\Http\Controllers\Api;
use App\Transformers\AvatarTransformer;
use StackTrace\Inspec\Route;
class UploadAvatarController
{
#[Route(
tags: 'Avatars',
summary: 'Upload a new avatar',
multipart: true,
request: [
'avatar:file' => 'Image file to upload',
'alt_text?:string' => 'Optional alt text',
],
responseCode: 201,
response: [
'data' => AvatarTransformer::class,
],
)]
public function __invoke()
{
//
}
}file fields automatically switch the request body content type to multipart/form-data. The multipart flag lets you force that content type even if there is no file field.
Inspec parses field definitions with StackTrace\Inspec\Property::compile(). The general shape is:
name[?][!][:type[,typeArg...]][|modifier:arg[,arg...]]
Examples:
| DSL | Meaning |
|---|---|
email:string |
Field named email with type string |
email!:string |
Marks the field as non-nullable |
email?:string |
Marks the field as optional |
email?!:string |
Marks the field as optional and non-nullable |
| `status:string | enum:draft,published` |
tags:array,string |
Array of strings |
avatar:file |
Binary file upload |
user:App\Transformers\UserTransformer |
Inline $ref to transformer schema |
Primitive definitions map the DSL type directly into the generated schema:
[
'id:string' => 'Resource UUID',
'count:integer' => 'Number of items',
'healthy:boolean' => 'Health status',
]The generator does not maintain a hardcoded type whitelist. Whatever you put in the DSL is written as the OpenAPI type, except that file is translated to type: string with format: binary.
Use array,<itemType> for primitive arrays:
[
'tags:array,string' => 'List of tags',
'scores:array,integer' => 'List of scores',
]For arrays of transformer-backed objects, make the field itself an array and use the transformer class as the value:
[
'data:array' => \App\Transformers\UserTransformer::class,
]Add |enum:... to emit enum values:
[
'status:string|enum:draft,published,archived' => 'Current status',
]You can also point enum: at a backed enum class name:
[
'status:string|enum:App\Enums\PostStatus' => 'Current status',
]When the enum modifier contains a single backed enum class name, Inspec expands it to that enum's case values.
If the array value is another array, Inspec builds an inline object:
[
'author' => [
'id:string' => 'Author UUID',
'name:string' => 'Author display name',
],
]Two metadata keys are reserved for the current object:
[
'meta' => [
'@description' => 'Extra metadata about the current result set',
'@example' => [
'requested_at' => '2026-04-01T12:00:00Z',
],
'requested_at:string' => 'ISO-8601 timestamp',
],
]@descriptionadds an object-level description.@exampleadds an object-level example.
If a field value is a transformer class string, Inspec resolves the transformer's #[Schema(...)] definition and emits a $ref:
[
'data' => \App\Transformers\UserTransformer::class,
]You can also use a Fractal transformer class as the type in the DSL key. This is equivalent — Inspec detects that the type is a transformer and emits the same $ref:
[
'user:' . \App\Transformers\UserTransformer::class => 'Owning user',
]Both forms register the transformer's schema as a reusable component and reference it via $ref.
StackTrace\Inspec\OpenAPIDocument also supports SchemaObject references when you build objects programmatically.
Use StackTrace\Inspec\RawSchema as a property value when you need an explicit OpenAPI schema instead of the property DSL. This works in transformer #[Schema(object: ...)] definitions, nested objects, and request or response objects:
#[Schema(object: [
'value' => new \StackTrace\Inspec\RawSchema([
'oneOf' => [
['type' => 'string', 'nullable' => true],
['type' => 'integer'],
['type' => 'boolean'],
['type' => 'array', 'items' => ['type' => 'string']],
],
], name: 'ProductFieldValue'),
'other_value' => new \StackTrace\Inspec\RawSchema([
'$ref' => '#/components/schemas/ProductFieldValue',
]),
])]Without name, the definition is emitted inline. With name, Inspec registers the unchanged definition under components.schemas and emits a $ref. Raw definitions are not parsed as DSL objects; include array items, nested properties, and any internal required list directly in the definition.
The parent property's nullability markers follow the rules below: nullable references use an allOf wrapper, and nullable inline schemas receive nullable: true (except boolean schemas). These adjustments do not mutate the supplied definition or registered component. Raw schemas retain explicitly supplied nullability when the parent does not add it, and follow the same current object-level requiredness limitation as other properties.
OpenAPIDocument::buildObject() uses different rules depending on what is being built.
For request, response, and pagination-meta objects:
fieldmeans "present" in the DSL and is emitted as nullable unless the type isboolean.field!means non-nullable.field?means optional and nullable.field?!means optional and non-nullable.
For schema objects created from #[Schema(...)]:
fieldmeans a normal non-nullable schema field.field?means a normal schema field that is emitted as nullable.!does not have separate meaning for schema objects.
Current caveat: object schemas do not currently emit an OpenAPI required array, so ? and ! are best understood as Inspec's internal field markers rather than a complete requiredness implementation.
Route, query, and request header parameters reuse the same DSL parser, but they are interpreted differently:
- Path parameters use
?to decide whether the generated parameter is markedrequired. - Query parameters use
!to decide whether the generated parameter is markedrequired. - Request headers use
!to decide whether the generated parameter is markedrequired. - Query parameter and request header enums are emitted from
|enum:.... - Parameter descriptions come from the array values you provide in
route,query, andheaders.
Examples:
[
'account:string' => 'Required path parameter',
'include?:string' => 'Optional query parameter',
'status!:string|enum:active,disabled' => 'Required query parameter',
'X-Account-Id!:string' => 'Required request header',
]Fractal transformers define reusable component schemas with #[StackTrace\Inspec\Schema(...)] on transform().
<?php
namespace App\Transformers;
use App\Models\User;
use League\Fractal\TransformerAbstract;
use StackTrace\Inspec\ExpandCollection;
use StackTrace\Inspec\ExpandItem;
use StackTrace\Inspec\Schema;
class UserTransformer extends TransformerAbstract
{
protected array $availableIncludes = ['team', 'roles'];
#[Schema(
object: [
'id:string' => 'User UUID',
'name:string' => 'Display name',
'email?:string' => 'Email address',
],
)]
public function transform(User $user): array
{
return [];
}
#[ExpandItem(TeamTransformer::class)]
public function includeTeam(User $user)
{
//
}
#[ExpandCollection(RoleTransformer::class)]
public function includeRoles(User $user)
{
//
}
}Transformer schema behavior:
- The schema name defaults to the transformer class basename without the
Transformersuffix. - You can override the component name with
#[Schema(name: 'CustomName', object: [...])]. #[ExpandItem(...)]and#[ExpandCollection(...)]are only considered on methods whose names start withinclude. Other methods are ignored.- The generated property name is derived from the method name in
snake_caseafter strippinginclude, soincludeTeam()becomesteamandincludeCoAuthors()becomesco_authors. - Each expand is emitted as a
type: objectwith a nesteddataproperty whose shape depends on the attribute:ExpandItem(Transformer::class)—datais a direct$refto the transformer schema.ExpandItem([A::class, B::class])—datais anallOflist of$refentries.ExpandCollection(Transformer::class)—datais atype: arraywhoseitemsis a$refto the transformer schema.
- Relation serialization is controlled by
RelationSerializer. The default isWrappedRelationSerializer('data'). UseApi::withRelationSerializer(new DirectRelationSerializer())when your serializer emits relations directly, ornew WrappedRelationSerializer('resource')when your wrapper key is notdata. ExpandCollectionaccepts only a single transformer class string. UseExpandItem([...])for multi-transformer unions.- All referenced transformer schemas are registered as reusable
#/components/schemas/entries.
Some documentation is inferred from the resolved Laravel route rather than the attribute itself:
Apienables Sanctum and broadcasting integrations by default. UsewithoutSanctum()orwithoutBroadcasting()to opt out for a specific documentation class, andwithSanctum()/withBroadcasting()to re-enable them explicitly.- When Sanctum is enabled, documented routes with the
auth:sanctummiddleware receivesecurity: [{ bearerAuth: [] }]. - APIs whose Sanctum-backed Laravel guard uses another name can call
withSanctumGuards('mobile')or pass an array such aswithSanctumGuards(['sanctum', 'mobile']). The default remainssanctum. - The
bearerAuthsecurity scheme is registered only when Sanctum is enabled and at least one included route uses anauth:<guard>middleware matching the configured guard names. - When broadcasting is enabled,
Apiautomatically documents the registered Laravel broadcasting routes needed for Pusher connections:/broadcasting/auth/broadcasting/user-auth
- Broadcasting auto-docs only appear when those real Laravel routes are actually registered, and they still respect
prefix(),filterPath(),filterRoute(),filterMethod(), and the generate-command filters. withBroadcasting()can accept a callback to customize each discovered broadcasting operation or returnnullto skip it.
Example:
$api
->prefix('api')
->withSanctumGuards(['sanctum', 'mobile'])
->withBroadcasting(function (\StackTrace\Inspec\Operation $operation, \Illuminate\Routing\Route $route) {
if ($route->uri() === 'api/broadcasting/user-auth') {
return null;
}
return $operation->tags('Realtime');
})
->withoutSanctum();This README describes current behavior as implemented today:
Route::$descriptionexists on the attribute, but is not currently written into the generated OpenAPI operation.paginatedResponseandcursorPaginatedResponseare typed asarray|string|null, but the current builder effectively supports transformer class strings only.- Request and response object schemas do not currently emit an OpenAPI
requiredarray.