jkbennemann / laravel-api-documentation
Zero-config OpenAPI 3.1.0 documentation generator for Laravel with plugin system, $ref deduplication, and multi-source analysis.
Installs: 478
Dependents: 0
Suggesters: 0
Security: 0
Stars: 1
Watchers: 1
Forks: 4
Open Issues: 0
pkg:composer/jkbennemann/laravel-api-documentation
Requires
- php: ^8.2
- illuminate/contracts: ^10.0 || ^11.0 || ^12.0
- nikic/php-parser: ^5.3
- php-openapi/openapi: ^2.0
- phpstan/phpdoc-parser: ^2.0
- spatie/laravel-package-tools: ^1.16
Requires (Dev)
- larastan/larastan: ^2.9 || ^3.0
- laravel/pint: ^1.14
- nunomaduro/collision: ^8.1.1||^7.10.0
- orchestra/testbench: ^10.0.0||^9.0.0||^8.22.0
- pestphp/pest: ^2.34 || ^3.0
- pestphp/pest-plugin-arch: ^2.7 || ^3.0
- pestphp/pest-plugin-laravel: ^2.3 || ^3.0
- phpstan/extension-installer: ^1.3
- phpstan/phpstan-deprecation-rules: ^1.1 || ^2.0
- phpstan/phpstan-phpunit: ^1.3 || ^2.0
- spatie/laravel-data: ^4.11
- spatie/laravel-ray: ^1.35
Suggests
- ext-yaml: Required for YAML output format support
- spatie/laravel-data: For automatic Spatie Data DTO schema extraction
- spatie/laravel-query-builder: For automatic query parameter extraction from Spatie Query Builder
- dev-main
- 0.4.6
- 0.4.5
- 0.4.4
- 0.4.3
- 0.4.2
- 0.4.1
- 0.4.0
- 0.3.7
- 0.3.6
- 0.3.5
- 0.3.4
- 0.3.3
- 0.3.2
- 0.3.1
- 0.3.0
- 0.2.9
- 0.2.8
- 0.2.7
- 0.2.6
- 0.2.5
- 0.2.4
- 0.2.3
- 0.2.2
- 0.2.1
- 0.2.0
- 0.1.8
- 0.1.7
- 0.1.6
- 0.1.5
- 0.1.4
- 0.1.3
- 0.1.2
- 0.1.1
- 0.1.0
- 0.0.1
- dev-feature/response-ref-dedup-and-type-accuracy
- dev-fix/multi-domain-generation
- dev-feature/improved-detection-and-examples
- dev-v2-rewrite
- dev-ci/add-github-actions-tests
- dev-fix/openapi-response-and-parameter-improvements
- dev-feat/enhance-features-for-dynamic-generation
- dev-feat/enhance-features
- dev-feat/extend-data-response-for-spatie-data-dto
- dev-feat/support-spatie-dto-as-response
This package is auto-updated.
Last update: 2026-02-18 21:28:25 UTC
README
A zero-configuration OpenAPI 3.1.0 documentation generator for Laravel. Analyzes your routes, controllers, form requests, and responses using AST parsing, PHP reflection, and optional runtime capture - then outputs a complete, valid OpenAPI spec without you writing a single annotation.
Requirements
- PHP 8.2+
- Laravel 10, 11, or 12
Installation
composer require jkbennemann/laravel-api-documentation
Publish the configuration file (optional):
php artisan vendor:publish --tag="api-documentation-config"
Generate your documentation:
php artisan api:generate
That's it. The package discovers your routes, analyzes your code, and writes an OpenAPI 3.1.0 spec to storage/app/public/api-documentation.json.
Table of Contents
- How It Works
- Quick Start
- Commands
- Runtime Capture
- PHP Attributes
- Configuration
- Tags & Documentation
- Documentation Viewers
- Output Formats
- Plugin System
- Built-in Plugins
- Creating a Plugin
- Integrations
- CI/CD Integration
- Security
- Credits
- License
How It Works
The package uses a 5-layer pipeline to generate documentation:
1. Route Discovery Collects routes, applies filters, extracts metadata
|
2. Analysis Pipeline Chains priority-ordered analyzers for requests, responses,
| query parameters, errors, and security schemes
3. Schema Registry Deduplicates schemas via content fingerprinting, manages $ref
|
4. Merge Engine Combines static analysis + runtime capture + attributes
| (configurable priority: static_first or captured_first)
5. OpenAPI Emission Builds final spec, resolves references, writes output
What Gets Analyzed Automatically
Requests:
FormRequestvalidation rules (types, formats, min/max, enums, patterns, required/optional)- Inline
$this->validate()andValidator::make()calls $request->get(),$request->query(),$request->integer()method calls in controller bodies- File upload detection (
file,imagerules) with automaticmultipart/form-datacontent type - Nested parameter structures (
user.profile.name)
Responses:
- Return type declarations (
JsonResource,JsonResponse,ResourceCollection, SpatieData) - Controller method body analysis (traces
response()->json([...])return statements) - PHPDoc
@returntypes including union types (UserData|AdminDataproducesoneOf) - Abort statements (
abort(404),abort_if()) for error responses - Paginated responses with
data,meta, andlinksstructure JsonResource::toArray()analysis (property types,$this->when(),$this->whenLoaded(),$this->merge())
Query Parameters:
FormRequestrules on GET routes become query parameters- PHPDoc
@queryParamannotations - Pagination detection (
paginate(),simplePaginate(),cursorPaginate()) addspage/per_pageparams
Errors:
FormRequestpresence adds422validation errorauth/sanctummiddleware adds401unauthorizedGate/authorizecalls add403forbidden- Route model binding adds
404not found throttlemiddleware adds429rate limited- Custom exception handler analysis for app-specific error schemas
Security:
auth:sanctum,auth:api,jwt.authmiddleware detected as Bearer token auth- OAuth scopes extracted from
scope:andscopes:middleware - Sanctum abilities extracted from
ability:andabilities:middleware
Quick Start
Zero-Config (Static Analysis)
For a standard Laravel API, no configuration is needed:
php artisan api:generate
The package reads your routes, controllers, form requests, and resources to produce a spec. This typically achieves ~70% schema accuracy, depending on how explicitly typed your code is.
With Runtime Capture (Recommended)
Enable runtime capture to use real API responses from your test suite, bringing accuracy to 95%+:
1. Add to phpunit.xml:
<php> <env name="DOC_CAPTURE_MODE" value="true"/> </php>
2. Register the middleware in your test setup (e.g., TestCase.php or a service provider used during testing):
use JkBennemann\LaravelApiDocumentation\Middleware\CaptureApiResponseMiddleware; // In a test service provider or TestCase::setUp() $this->app['router']->pushMiddlewareToGroup('api', CaptureApiResponseMiddleware::class);
3. Run your tests, then generate:
php artisan test
php artisan api:generate
The middleware captures request/response data to .schemas/responses/ during test runs. The generator merges this with static analysis to produce accurate schemas with real examples. See Runtime Capture for details.
With Attributes (Precision)
For routes where auto-detection falls short (proxy controllers, dynamic responses), add PHP 8 attributes:
use JkBennemann\LaravelApiDocumentation\Attributes\{Tag, Summary, DataResponse}; #[Tag('Users')] #[Summary('List all users')] #[DataResponse(200, description: 'Paginated user list', resource: UserResource::class)] public function index(): ResourceCollection { return UserResource::collection(User::paginate()); }
Commands
api:generate - Generate Documentation
php artisan api:generate [options]
| Option | Description |
|---|---|
--format=json |
Output format: json, yaml, or postman |
--domain= |
Generate for a specific domain only |
--route= |
Generate for a single route URI (for debugging) |
--method=GET |
HTTP method when using --route |
--dev |
Include development servers in output |
--clear-cache |
Clear AST cache before generating |
--verbose-analysis |
Show analyzer decisions during generation |
--watch |
Watch for file changes and regenerate automatically |
Examples:
# Standard generation php artisan api:generate # YAML output php artisan api:generate --format=yaml # Debug a single route php artisan api:generate --route=api/users --method=GET --verbose-analysis # Watch mode during development php artisan api:generate --watch # Export as Postman collection php artisan api:generate --format=postman
api:lint - Lint Spec Quality
php artisan api:lint [options]
| Option | Description |
|---|---|
--file= |
Path to an existing OpenAPI JSON file to lint |
--domain= |
Generate and lint a specific domain |
--json |
Output results as JSON |
Reports coverage (summaries, descriptions, examples, error responses, request/response bodies), issues (errors, warnings), and a quality score (0-100) with a letter grade.
php artisan api:lint # Score: 82/100 (B) # Coverage: summaries 95%, descriptions 60%, examples 80%
api:diff - Detect Breaking Changes
php artisan api:diff <old-spec> <new-spec> [options]
| Option | Description |
|---|---|
--fail-on-breaking |
Exit with code 1 if breaking changes are found |
--json |
Output results as JSON |
Compares two OpenAPI specs and reports breaking vs non-breaking changes. Detects removed endpoints, removed response fields, type changes, new required parameters, and added auth requirements.
# Compare specs php artisan api:diff public/api-v1.json public/api-v2.json # Use in CI to block breaking changes php artisan api:diff old.json new.json --fail-on-breaking
api:types - Generate TypeScript Definitions
php artisan api:types [options]
| Option | Description |
|---|---|
--output= |
Output file path (default: resources/js/types/api.d.ts) |
--file= |
Path to an existing OpenAPI JSON file |
--stdout |
Print to stdout instead of writing to file |
Generates TypeScript interfaces from your OpenAPI component schemas, including request/response types for operations that have an operationId.
api:clear-cache - Clear Caches
php artisan api:clear-cache [options]
| Option | Description |
|---|---|
--ast |
Clear AST analysis cache only |
--captures |
Clear captured responses only |
Without flags, clears both AST and capture caches.
api:plugins - List Registered Plugins
php artisan api:plugins
Shows all registered plugins with their names, priorities, and capabilities (which interfaces they implement).
Runtime Capture
Runtime capture records real HTTP request/response data during your test runs. This data is then merged with static analysis during generation.
How It Works
- The
CaptureApiResponseMiddlewareintercepts API responses inlocal/testingenvironments (never in production) - For each response, it infers an OpenAPI schema from the JSON structure and stores it to
.schemas/responses/ - Captures are idempotent - if the schema structure hasn't changed between test runs, the file is not rewritten (no noisy git diffs)
- Sensitive data (passwords, tokens, API keys, credit card numbers) is automatically redacted
- During
api:generate, the captured schemas are merged with static analysis results
Configuration
In config/api-documentation.php:
'capture' => [ 'enabled' => env('DOC_CAPTURE_MODE', false), 'storage_path' => base_path('.schemas/responses'), 'capture' => [ 'requests' => true, // Capture request bodies and query params 'responses' => true, // Capture response bodies 'headers' => true, // Capture relevant headers 'examples' => true, // Store sanitized examples ], 'sanitize' => [ 'enabled' => true, 'sensitive_keys' => [ 'password', 'token', 'secret', 'api_key', 'access_token', 'credit_card', 'cvv', 'ssn', // ... and more ], 'redacted_value' => '***REDACTED***', ], 'rules' => [ 'max_size' => 1024 * 100, // Skip responses over 100KB 'exclude_routes' => ['telescope/*', 'horizon/*', '_debugbar/*', 'sanctum/*'], ], ],
Merge Priority
Control whether static analysis or captured data takes precedence:
'analysis' => [ 'priority' => 'static_first', // or 'captured_first' ],
static_first(default): Attributes > Static Analysis > Runtime Capturecaptured_first: Attributes > Runtime Capture > Static Analysis
Version Control
Consider committing .schemas/responses/ to version control. Captures are idempotent, so unchanged schemas produce no git diffs. This gives you:
- Documentation that works without running the full test suite
- A record of your API's response shapes over time
- Faster CI builds (skip test run, generate from committed captures)
PHP Attributes
Attributes give you precise control when auto-detection needs help. They always take the highest priority.
#[Tag]
Groups operations in the generated documentation. Applied at class or method level. Optionally includes a description (supports Markdown) that appears in ReDoc/Scalar as introductory content for the tag section.
use JkBennemann\LaravelApiDocumentation\Attributes\Tag; #[Tag('Users')] class UserController extends Controller {} // With description #[Tag('Widgets', description: 'Operations for managing widgets and their configurations.')] public function index() {} // Multiple tags #[Tag(['Users', 'Admin'])] public function promoteUser() {}
| Parameter | Type | Default | Description |
|---|---|---|---|
value |
string|array|null |
null |
Tag name or array of tag names |
description |
string|null |
null |
Tag description (Markdown supported). Can also be set via config. |
Precedence: Config
tagsdescriptions override#[Tag]attribute descriptions. See Tags & Documentation.
#[Summary] and #[Description]
Set the operation summary (short) and description (detailed). Also inferred from PHPDoc if not provided.
use JkBennemann\LaravelApiDocumentation\Attributes\Summary; use JkBennemann\LaravelApiDocumentation\Attributes\Description; #[Summary('List all users')] #[Description('Returns a paginated list of users with optional filtering by status and role.')] public function index() {}
#[DataResponse]
Define response schemas explicitly. Repeatable for multiple status codes.
use JkBennemann\LaravelApiDocumentation\Attributes\DataResponse; #[DataResponse(200, description: 'User details', resource: UserResource::class)] #[DataResponse(404, description: 'User not found', resource: ['message' => 'string'])] public function show(string $id) {}
| Parameter | Type | Default | Description |
|---|---|---|---|
status |
int |
(required) | HTTP status code |
description |
string |
'' |
Response description |
resource |
string|array|null |
[] |
Resource class, Spatie Data class, or inline schema |
headers |
array |
[] |
Response headers (['X-Token' => 'description']) |
isCollection |
bool |
false |
Whether the response is a collection |
#[Parameter]
Enhance request body or response properties. Applied at class level (resources) or method level (form requests). Repeatable.
use JkBennemann\LaravelApiDocumentation\Attributes\Parameter; // On a FormRequest's rules() method #[Parameter(name: 'email', required: true, format: 'email', description: 'User email', example: 'john@example.com')] #[Parameter(name: 'role', type: 'string', description: 'User role', deprecated: true)] public function rules(): array { /* ... */ } // On a JsonResource's toArray() method #[Parameter(name: 'id', type: 'string', format: 'uuid', description: 'Unique identifier')] #[Parameter(name: 'avatar_url', type: 'string', format: 'uri', description: 'Profile image URL')] public function toArray($request): array { /* ... */ }
| Parameter | Type | Default | Description |
|---|---|---|---|
name |
string |
(required) | Property name |
required |
bool |
false |
Whether the property is required |
type |
string |
'string' |
OpenAPI type (also accepts aliases: date, email, uuid, etc.) |
format |
string|null |
null |
OpenAPI format |
description |
string |
'' |
Property description |
deprecated |
bool |
false |
Mark as deprecated |
example |
mixed |
null |
Example value |
nullable |
bool |
false |
Allow null values |
minLength |
int|null |
null |
Minimum string length |
maxLength |
int|null |
null |
Maximum string length |
items |
string|null |
null |
Array item type |
resource |
string|null |
null |
Nested resource class |
Type aliases are automatically normalized to valid OpenAPI types:
| Alias | Becomes |
|---|---|
date |
type: "string", format: "date" |
datetime, date-time, timestamp |
type: "string", format: "date-time" |
time |
type: "string", format: "time" |
email |
type: "string", format: "email" |
url, uri |
type: "string", format: "uri" |
uuid |
type: "string", format: "uuid" |
ip, ipv4 |
type: "string", format: "ipv4" |
ipv6 |
type: "string", format: "ipv6" |
binary |
type: "string", format: "binary" |
byte |
type: "string", format: "byte" |
password |
type: "string", format: "password" |
int |
type: "integer" |
bool |
type: "boolean" |
float, double |
type: "number" |
#[PathParameter]
Document path parameters. Repeatable.
use JkBennemann\LaravelApiDocumentation\Attributes\PathParameter; #[PathParameter(name: 'id', type: 'string', format: 'uuid', description: 'User ID', example: '550e8400-e29b-41d4-a716-446655440000')] public function show(string $id) {}
| Parameter | Type | Default | Description |
|---|---|---|---|
name |
string |
(required) | Parameter name (must match route parameter) |
type |
string |
'string' |
Parameter type |
format |
string|null |
null |
Parameter format |
description |
string |
'' |
Parameter description |
required |
bool |
true |
Whether required |
example |
mixed |
null |
Example value |
#[QueryParameter]
Document query parameters explicitly. Repeatable.
use JkBennemann\LaravelApiDocumentation\Attributes\QueryParameter; #[QueryParameter(name: 'status', description: 'Filter by status', enum: ['active', 'inactive', 'banned'])] #[QueryParameter(name: 'per_page', type: 'integer', description: 'Items per page', example: 25)] public function index() {}
| Parameter | Type | Default | Description |
|---|---|---|---|
name |
string |
(required) | Parameter name |
type |
string |
'string' |
Parameter type |
format |
string|null |
null |
Parameter format |
description |
string |
'' |
Parameter description |
required |
bool |
false |
Whether required |
example |
mixed |
null |
Example value |
enum |
array|null |
null |
Allowed values |
#[ResponseHeader]
Document response headers. Repeatable.
use JkBennemann\LaravelApiDocumentation\Attributes\ResponseHeader; #[ResponseHeader(name: 'X-Request-Id', description: 'Unique request identifier', format: 'uuid')] #[ResponseHeader(name: 'X-RateLimit-Remaining', type: 'integer', description: 'Remaining requests')] public function index() {}
#[RequestBody] and #[ResponseBody]
Low-level control over request/response schemas when you need full override.
use JkBennemann\LaravelApiDocumentation\Attributes\RequestBody; use JkBennemann\LaravelApiDocumentation\Attributes\ResponseBody; #[RequestBody(description: 'Webhook payload', contentType: 'application/json', dataClass: WebhookPayload::class)] #[ResponseBody(statusCode: 202, description: 'Accepted')] public function handleWebhook() {}
#[ExcludeFromDocs]
Exclude a controller or specific method from documentation.
use JkBennemann\LaravelApiDocumentation\Attributes\ExcludeFromDocs; // Exclude entire controller #[ExcludeFromDocs] class InternalController extends Controller {} // Exclude single method class UserController extends Controller { #[ExcludeFromDocs] public function debug() {} }
#[AdditionalDocumentation]
Link to external documentation.
use JkBennemann\LaravelApiDocumentation\Attributes\AdditionalDocumentation; #[AdditionalDocumentation(url: 'https://docs.example.com/auth', description: 'Authentication guide')] public function login() {}
#[DocumentationFile]
Route an endpoint to a specific documentation file (for multi-file setups).
use JkBennemann\LaravelApiDocumentation\Attributes\DocumentationFile; #[DocumentationFile('internal-api')] class InternalApiController extends Controller {}
#[IgnoreDataParameter]
Exclude a Spatie Data property from the request body schema.
use JkBennemann\LaravelApiDocumentation\Attributes\IgnoreDataParameter; #[IgnoreDataParameter(parameters: 'internal_field')] public function store(CreateUserData $data) {}
PHPDoc Annotations
The package also reads PHPDoc blocks:
/** * Get a list of users * * Returns all active users with optional filtering. * * @queryParam per_page integer Number of results per page. Example: 25 * @queryParam search string Search by name or email. Example: john * @queryParam status string Filter by account status. Example: active * * @return \Illuminate\Http\Resources\Json\ResourceCollection<UserResource> * * @deprecated Use /api/v2/users instead */ public function index(Request $request): ResourceCollection {}
- The first line becomes the
summary, the rest becomes thedescription @queryParamentries are extracted as query parameters@returntype is used for response schema detection@deprecatedmarks the operation as deprecated
Configuration
After publishing the config (php artisan vendor:publish --tag="api-documentation-config"), the file is at config/api-documentation.php. Key sections:
OpenAPI Metadata
'open_api_version' => '3.1.0', 'version' => '1.0.0', 'title' => 'My API', // Defaults to APP_NAME
Route Filtering
'include_vendor_routes' => false, 'include_closure_routes' => false, 'excluded_routes' => [ 'telescope/*', 'horizon/*', ], 'excluded_methods' => ['HEAD', 'OPTIONS'],
Servers
'servers' => [ ['url' => env('APP_URL', 'http://localhost'), 'description' => 'Local'], ['url' => 'https://api.example.com', 'description' => 'Production'], ],
Analysis
'analysis' => [ 'priority' => 'static_first', // 'static_first' or 'captured_first' 'cache_ttl' => 3600, // AST cache TTL in seconds (0 disables) 'cache_path' => null, // Defaults to storage_path() ],
Code Samples
'code_samples' => [ 'enabled' => false, 'languages' => ['bash', 'javascript', 'php', 'python'], 'base_url' => null, // null uses '{baseUrl}' placeholder ],
When enabled, adds x-codeSamples to each operation with working cURL, fetch, Guzzle, and requests examples.
Error Responses
'error_responses' => [ 'enabled' => true, 'defaults' => [ 'status_messages' => [ '400' => 'The request could not be processed.', '401' => 'Authentication credentials are required.', '403' => 'You do not have permission.', '404' => 'The requested resource was not found.', '422' => 'The request contains invalid data.', '429' => 'Too many requests.', '500' => 'An internal server error occurred.', ], ], ],
Validation Rule Mappings
'smart_requests' => [ 'rule_types' => [ 'string' => ['type' => 'string'], 'integer' => ['type' => 'integer'], 'boolean' => ['type' => 'boolean'], 'email' => ['type' => 'string', 'format' => 'email'], 'uuid' => ['type' => 'string', 'format' => 'uuid'], 'url' => ['type' => 'string', 'format' => 'uri'], 'file' => ['type' => 'string', 'format' => 'binary'], 'image' => ['type' => 'string', 'format' => 'binary'], // ... and more ], ],
Tags & Documentation
Enrich your documentation viewers (ReDoc, Scalar) with tag descriptions, navigation groups, documentation-only pages, and external links. All settings are zero-config by default — empty arrays and null values produce no output.
Tag Descriptions
Add Markdown descriptions to tags. These appear as introductory content in tag sections.
// config/api-documentation.php 'tags' => [ 'Users' => 'Operations for managing user accounts.', 'Billing' => '## Billing API\nAll endpoints require an active subscription.', ],
Descriptions can also be set via the #[Tag] attribute:
#[Tag('Users', description: 'Operations for managing user accounts.')]
Config descriptions take precedence over attribute descriptions.
Tag Groups (x-tagGroups)
Group tags into sections for the ReDoc/Scalar navigation sidebar:
'tag_groups' => [ ['name' => 'User Management', 'tags' => ['Users', 'Roles', 'Permissions']], ['name' => 'Commerce', 'tags' => ['Products', 'Orders', 'Billing']], ],
This emits the x-tagGroups vendor extension recognized by ReDoc and Scalar. By default, any tags not assigned to a group are automatically collected into an "Other" group so they remain visible in the sidebar. To instead hide ungrouped tags (the default ReDoc/Scalar behavior), set:
'tag_groups_include_ungrouped' => false,
Trait Tags (x-traitTag)
Add documentation-only tags that appear in the sidebar but aren't associated with any operations. Useful for guides, introductions, or changelogs:
'trait_tags' => [ [ 'name' => 'Getting Started', 'description' => "# Welcome\n\nThis API uses Bearer token authentication. See the [Authentication](#section/Authentication) section for details.", ], [ 'name' => 'Changelog', 'description' => "# Changelog\n\n## v2.0\n- New billing endpoints\n- Improved error responses", ], ],
Trait tags are emitted with x-traitTag: true and support full Markdown in the description.
External Documentation
Add a spec-level link to external documentation:
'external_docs' => [ 'url' => 'https://docs.example.com', 'description' => 'Full developer documentation', ],
Set to null (default) to omit from the spec.
Domain Overrides
All four settings (tags, tag_groups, trait_tags, external_docs) can be overridden per domain:
'domains' => [ 'public' => [ 'title' => 'Public API', 'tags' => ['Users' => 'Public user endpoints.'], 'tag_groups' => [ ['name' => 'Core', 'tags' => ['Users', 'Products']], ], 'external_docs' => ['url' => 'https://docs.example.com'], ], 'internal' => [ 'title' => 'Internal API', 'tags' => ['Admin' => 'Internal admin operations.'], ], ],
Multi-Domain Support
Generate separate specs for different API domains:
'domains' => [ 'public' => [ 'title' => 'Public API', 'main' => 'https://api.example.com', 'servers' => [ ['url' => 'https://api.example.com', 'description' => 'Production'], ], ], 'internal' => [ 'title' => 'Internal API', 'main' => 'https://internal.example.com', 'servers' => [ ['url' => 'https://internal.example.com', 'description' => 'Internal'], ], ], ],
php artisan api:generate --domain=public
Multi-File Support
Output multiple documentation files from a single app:
'ui' => [ 'storage' => [ 'files' => [ 'default' => [ 'name' => 'Public API', 'filename' => 'api-documentation.json', 'process' => true, ], 'internal' => [ 'name' => 'Internal API', 'filename' => 'internal-api.json', 'process' => true, ], ], ], ],
Use #[DocumentationFile('internal')] on controllers to route them to a specific file.
Documentation Viewers
Three built-in documentation UIs are available. Enable them in your config:
'ui' => [ 'default' => 'swagger', // 'swagger', 'redoc', or 'scalar' 'swagger' => [ 'enabled' => true, 'route' => '/documentation/swagger', 'version' => '5.17.14', 'middleware' => ['web'], ], 'redoc' => [ 'enabled' => true, 'route' => '/documentation/redoc', 'version' => '2.2.0', 'middleware' => ['web'], ], 'scalar' => [ 'enabled' => true, 'route' => '/documentation/scalar', 'version' => '2.2.0', 'middleware' => ['web'], ], ],
When any UI is enabled, a default hub page is registered at /documentation that redirects to your chosen default viewer.
To publish and customize the view templates:
php artisan vendor:publish --tag="api-documentation-views"
Output Formats
JSON (default)
php artisan api:generate
# Output: storage/app/public/api-documentation.json
YAML
Requires the ext-yaml PHP extension, or falls back to a built-in converter.
php artisan api:generate --format=yaml
Postman Collection
Exports a Postman Collection v2.1 file, grouped by tags, with auth headers, path/query parameters, and request body examples.
php artisan api:generate --format=postman
TypeScript Definitions
php artisan api:types
# Output: resources/js/types/api.d.ts
Plugin System
The package is built around 6 plugin interfaces. Each interface represents a specific analysis capability. All built-in analyzers use the same interfaces, so plugins have the same power as core functionality.
Plugin Interfaces
| Interface | Purpose | Method Signature |
|---|---|---|
RequestBodyExtractor |
Extract request body schemas | extract(AnalysisContext $ctx): ?SchemaResult |
ResponseExtractor |
Extract response schemas | extract(AnalysisContext $ctx): array (of ResponseResult) |
QueryParameterExtractor |
Extract query parameters | extract(AnalysisContext $ctx): array (of ParameterResult) |
SecuritySchemeDetector |
Detect auth schemes | detect(AnalysisContext $ctx): ?array |
OperationTransformer |
Post-process operations | transform(array $operation, AnalysisContext $ctx): array |
ExceptionSchemaProvider |
Custom exception schemas | provides(string $exceptionClass): bool + getResponse(string $exceptionClass): ResponseResult |
Every plugin must implement the base Plugin interface:
use JkBennemann\LaravelApiDocumentation\Contracts\Plugin; interface Plugin { public function name(): string; public function boot(PluginRegistry $registry): void; public function priority(): int; }
Priority System
Analyzers run in priority order (highest first). The first non-null result wins for request bodies; all results are collected for responses and query parameters.
| Range | Used By |
|---|---|
| 100 | Attribute-based analyzers (manual overrides) |
| 80-90 | Core static analyzers (FormRequest, ReturnType) |
| 60-70 | Runtime capture analyzers |
| 40-50 | Built-in plugins (BearerAuth, Pagination, Spatie) |
| 1-39 | Community plugins (recommended range) |
Registering Plugins
Via config:
// config/api-documentation.php 'plugins' => [ App\Documentation\MyPlugin::class, ],
Via Composer auto-discovery (for package authors):
{
"extra": {
"api-documentation": {
"plugins": [
"Vendor\\Package\\MyPlugin"
]
}
}
}
Programmatically at runtime:
use JkBennemann\LaravelApiDocumentation\LaravelApiDocumentation; LaravelApiDocumentation::extend(new MyPlugin());
Built-in Plugins
BearerAuthPlugin
Always active. Detects Bearer token authentication from auth:sanctum, auth:api, and jwt.auth middleware. Extracts OAuth scopes and Sanctum abilities.
PaginationPlugin
Always active. Detects paginate(), simplePaginate(), and cursorPaginate() calls via AST analysis. Wraps response schemas with data/meta/links pagination envelope.
CodeSamplePlugin
Enabled when code_samples.enabled is true in config. Generates working code examples in bash (cURL), JavaScript (fetch), PHP (Guzzle), and Python (requests) with proper auth headers and request bodies.
SpatieDataPlugin
Auto-detected when spatie/laravel-data is installed. Extracts request body schemas from Spatie Data DTO constructor properties. Handles optional properties, Lazy types, and nested DTOs with $ref deduplication.
SpatieQueryBuilderPlugin
Auto-detected when spatie/laravel-query-builder is installed. Extracts filter[...], sort, include, and fields query parameters from allowedFilters(), allowedSorts(), allowedIncludes(), and allowedFields() calls.
JsonApiPlugin
Auto-detected when timacdonald/json-api is installed. Generates proper JSON:API response schemas (data/attributes/relationships/links), sets content type to application/vnd.api+json, and adds the Accept header.
LaravelActionsPlugin
Auto-detected when lorisleiva/laravel-actions is installed. Extracts request schemas from Action classes using the AsController trait by analyzing rules() methods and handle() parameters.
Creating a Plugin
Here is a complete example of a plugin that adds a custom header to every operation:
<?php namespace App\Documentation; use JkBennemann\LaravelApiDocumentation\Contracts\OperationTransformer; use JkBennemann\LaravelApiDocumentation\Contracts\Plugin; use JkBennemann\LaravelApiDocumentation\Data\AnalysisContext; use JkBennemann\LaravelApiDocumentation\PluginRegistry; class CorrelationIdPlugin implements Plugin, OperationTransformer { public function name(): string { return 'correlation-id'; } public function boot(PluginRegistry $registry): void { $registry->addOperationTransformer($this, priority: 30); } public function priority(): int { return 30; } public function transform(array $operation, AnalysisContext $ctx): array { $operation['parameters'][] = [ 'name' => 'X-Correlation-ID', 'in' => 'header', 'required' => false, 'description' => 'Optional correlation ID for request tracing', 'schema' => ['type' => 'string', 'format' => 'uuid'], ]; return $operation; } }
Register it:
// config/api-documentation.php 'plugins' => [ App\Documentation\CorrelationIdPlugin::class, ],
Plugin with Multiple Capabilities
A plugin can implement multiple interfaces:
class MyPlugin implements Plugin, ResponseExtractor, QueryParameterExtractor { public function name(): string { return 'my-plugin'; } public function priority(): int { return 35; } public function boot(PluginRegistry $registry): void { $registry->addResponseExtractor($this, priority: 35); $registry->addQueryExtractor($this, priority: 35); } public function extract(AnalysisContext $ctx): array { // This method serves both interfaces - differentiate // by checking what's being requested via the context return []; } }
The AnalysisContext Object
Every analyzer receives an AnalysisContext that provides:
$ctx->routeInfo- Route metadata (URI, methods, middleware, parameters)$ctx->reflectionMethod- PHPReflectionMethodof the controller action$ctx->reflectionClass- PHPReflectionClassof the controller$ctx->ast- Parsed AST statements of the controller method body$ctx->classAttributes- PHP 8 attributes on the controller class$ctx->methodAttributes- PHP 8 attributes on the method
Plugin Safety
Plugins that throw during boot() are automatically unregistered and logged. A failing plugin never breaks documentation generation for other routes.
Community Plugin Ideas
Below are detailed implementation examples for plugins the community could build. Each example is a complete, working plugin that can be copied into your project and customized.
API Key Authentication
Detects custom middleware that validates API keys via headers or query parameters.
<?php namespace App\Documentation; use JkBennemann\LaravelApiDocumentation\Contracts\Plugin; use JkBennemann\LaravelApiDocumentation\Contracts\SecuritySchemeDetector; use JkBennemann\LaravelApiDocumentation\Data\AnalysisContext; use JkBennemann\LaravelApiDocumentation\PluginRegistry; class ApiKeyAuthPlugin implements Plugin, SecuritySchemeDetector { /** * Map your middleware aliases to their API key configuration. * Adjust these to match your application's middleware names. */ private const MIDDLEWARE_MAP = [ 'api-key' => ['header', 'X-API-Key'], 'api_key' => ['header', 'X-API-Key'], 'api.key' => ['header', 'X-API-Key'], 'client-token' => ['header', 'X-Client-Token'], ]; public function name(): string { return 'api-key-auth'; } public function boot(PluginRegistry $registry): void { $registry->addSecurityDetector($this, 45); } public function priority(): int { return 45; } public function detect(AnalysisContext $ctx): ?array { foreach ($ctx->route->middleware as $middleware) { $name = explode(':', $middleware)[0]; if (isset(self::MIDDLEWARE_MAP[$name])) { [$in, $paramName] = self::MIDDLEWARE_MAP[$name]; return [ 'name' => 'apiKeyAuth', 'scheme' => [ 'type' => 'apiKey', 'in' => $in, // 'header' or 'query' 'name' => $paramName, // The header/query parameter name ], ]; } } return null; } }
RFC 7807 Problem Details
Maps exceptions to standardized Problem Details responses. This is the only interface (ExceptionSchemaProvider) with no built-in implementation — a great opportunity for a community package.
<?php namespace App\Documentation; use JkBennemann\LaravelApiDocumentation\Contracts\ExceptionSchemaProvider; use JkBennemann\LaravelApiDocumentation\Contracts\Plugin; use JkBennemann\LaravelApiDocumentation\Data\ResponseResult; use JkBennemann\LaravelApiDocumentation\Data\SchemaObject; use JkBennemann\LaravelApiDocumentation\PluginRegistry; class ProblemDetailsPlugin implements Plugin, ExceptionSchemaProvider { /** * Map exception classes to their Problem Details type URI and title. * Customize this for your application's exception hierarchy. */ private const EXCEPTION_MAP = [ \Illuminate\Auth\AuthenticationException::class => [ 'status' => 401, 'type' => 'https://httpstatuses.com/401', 'title' => 'Unauthenticated', ], \Illuminate\Auth\Access\AuthorizationException::class => [ 'status' => 403, 'type' => 'https://httpstatuses.com/403', 'title' => 'Forbidden', ], \Illuminate\Database\Eloquent\ModelNotFoundException::class => [ 'status' => 404, 'type' => 'https://httpstatuses.com/404', 'title' => 'Resource Not Found', ], \Illuminate\Validation\ValidationException::class => [ 'status' => 422, 'type' => 'https://httpstatuses.com/422', 'title' => 'Validation Failed', ], ]; public function name(): string { return 'problem-details'; } public function boot(PluginRegistry $registry): void { $registry->addExceptionSchemaProvider($this); } public function priority(): int { return 35; } public function provides(string $exceptionClass): bool { return isset(self::EXCEPTION_MAP[$exceptionClass]); } public function getResponse(string $exceptionClass): ResponseResult { $config = self::EXCEPTION_MAP[$exceptionClass]; $properties = [ 'type' => new SchemaObject(type: 'string', format: 'uri', example: $config['type']), 'title' => new SchemaObject(type: 'string', example: $config['title']), 'status' => new SchemaObject(type: 'integer', example: $config['status']), 'detail' => SchemaObject::string(), 'instance' => new SchemaObject(type: 'string', format: 'uri'), ]; // Validation errors include an additional 'errors' object if ($exceptionClass === \Illuminate\Validation\ValidationException::class) { $properties['errors'] = SchemaObject::object(); } return new ResponseResult( statusCode: $config['status'], schema: SchemaObject::object($properties, ['type', 'title', 'status']), description: $config['title'], contentType: 'application/problem+json', source: 'plugin:problem-details', ); } }
League Fractal Transformers
Detects Fractal transformers and extracts response schemas by analyzing the transform() method's return array via AST parsing.
<?php namespace App\Documentation; use JkBennemann\LaravelApiDocumentation\Contracts\Plugin; use JkBennemann\LaravelApiDocumentation\Contracts\ResponseExtractor; use JkBennemann\LaravelApiDocumentation\Data\AnalysisContext; use JkBennemann\LaravelApiDocumentation\Data\ResponseResult; use JkBennemann\LaravelApiDocumentation\Data\SchemaObject; use JkBennemann\LaravelApiDocumentation\PluginRegistry; use PhpParser\Node; use PhpParser\Node\ArrayItem; use PhpParser\Node\Scalar\String_; use PhpParser\Node\Stmt\Return_; use PhpParser\NodeFinder; use PhpParser\ParserFactory; class FractalPlugin implements Plugin, ResponseExtractor { public function name(): string { return 'league-fractal'; } public function boot(PluginRegistry $registry): void { // Only activate when Fractal is installed if (! class_exists(\League\Fractal\TransformerAbstract::class)) { return; } $registry->addResponseExtractor($this, 35); } public function priority(): int { return 35; } public function extract(AnalysisContext $ctx): array { if (! $ctx->hasAst() || ! $ctx->hasReflection()) { return []; } // Find Fractal transformer usage in the controller method's AST $transformerClass = $this->detectTransformer($ctx); if ($transformerClass === null) { return []; } // Parse the transformer's transform() method to extract the schema $schema = $this->analyzeTransformer($transformerClass); if ($schema === null) { return []; } return [ new ResponseResult( statusCode: 200, schema: $schema, description: 'Success', source: 'plugin:fractal', ), ]; } /** * Look for patterns like: * $fractal->item($model, new UserTransformer) * $fractal->collection($models, new UserTransformer) * return fractal($model, new UserTransformer)->toArray() */ private function detectTransformer(AnalysisContext $ctx): ?string { $nodeFinder = new NodeFinder; $news = $nodeFinder->findInstanceOf( $ctx->astNode->stmts ?? [], Node\Expr\New_::class, ); foreach ($news as $new) { if (! $new->class instanceof Node\Name) { continue; } $className = $new->class->toString(); $resolved = $this->resolveClassName($className, $ctx); if ($resolved !== null && class_exists($resolved) && is_subclass_of($resolved, \League\Fractal\TransformerAbstract::class) ) { return $resolved; } } return null; } private function analyzeTransformer(string $transformerClass): ?SchemaObject { try { $ref = new \ReflectionClass($transformerClass); $fileName = $ref->getFileName(); if ($fileName === false || ! file_exists($fileName)) { return null; } } catch (\Throwable) { return null; } // Parse the transformer file's AST $parser = (new ParserFactory)->createForNewestSupportedVersion(); $stmts = $parser->parse(file_get_contents($fileName)); if ($stmts === null) { return null; } // Find the transform() method $nodeFinder = new NodeFinder; $methods = $nodeFinder->findInstanceOf($stmts, Node\Stmt\ClassMethod::class); foreach ($methods as $method) { if ($method->name->toString() !== 'transform') { continue; } return $this->extractSchemaFromTransformMethod($method); } return null; } private function extractSchemaFromTransformMethod(Node\Stmt\ClassMethod $method): ?SchemaObject { $nodeFinder = new NodeFinder; $returns = $nodeFinder->findInstanceOf($method->stmts ?? [], Return_::class); foreach ($returns as $return) { if (! $return->expr instanceof Node\Expr\Array_) { continue; } $properties = []; foreach ($return->expr->items as $item) { if (! $item instanceof ArrayItem || ! $item->key instanceof String_) { continue; } $properties[$item->key->value] = $this->inferType($item->value); } if (! empty($properties)) { return SchemaObject::object($properties); } } return null; } private function inferType(Node\Expr $expr): SchemaObject { // $model->id, $model->count — infer from property name if ($expr instanceof Node\Expr\PropertyFetch && $expr->name instanceof Node\Identifier ) { return $this->inferFromName($expr->name->toString()); } // (int) $value, (bool) $value if ($expr instanceof Node\Expr\Cast\Int_) { return SchemaObject::integer(); } if ($expr instanceof Node\Expr\Cast\Bool_) { return SchemaObject::boolean(); } if ($expr instanceof Node\Expr\Cast\Double) { return SchemaObject::number('double'); } return SchemaObject::string(); } private function inferFromName(string $name): SchemaObject { return match (true) { $name === 'id' => SchemaObject::integer(), str_ends_with($name, '_id') => SchemaObject::integer(), str_ends_with($name, '_count') => SchemaObject::integer(), str_starts_with($name, 'is_') || str_starts_with($name, 'has_') => SchemaObject::boolean(), str_ends_with($name, '_at') => SchemaObject::string('date-time'), str_contains($name, 'email') => SchemaObject::string('email'), str_contains($name, 'url') || str_contains($name, 'link') => SchemaObject::string('uri'), str_contains($name, 'uuid') => SchemaObject::string('uuid'), str_contains($name, 'price') || str_contains($name, 'amount') => SchemaObject::number('double'), default => SchemaObject::string(), }; } private function resolveClassName(string $shortName, AnalysisContext $ctx): ?string { // Try fully qualified if (class_exists($shortName)) { return $shortName; } // Try same namespace as controller $controllerClass = $ctx->controllerClass(); if ($controllerClass !== null) { $ns = substr($controllerClass, 0, (int) strrpos($controllerClass, '\\')); $fqcn = $ns . '\\' . $shortName; if (class_exists($fqcn)) { return $fqcn; } // Try Transformers sub-namespace $parentNs = substr($ns, 0, (int) strrpos($ns, '\\')); $fqcn = $parentNs . '\\Transformers\\' . $shortName; if (class_exists($fqcn)) { return $fqcn; } } return null; } }
Versioning Headers
Detects API versioning strategies and documents the version parameter on each operation.
<?php namespace App\Documentation; use JkBennemann\LaravelApiDocumentation\Contracts\OperationTransformer; use JkBennemann\LaravelApiDocumentation\Contracts\Plugin; use JkBennemann\LaravelApiDocumentation\Data\AnalysisContext; use JkBennemann\LaravelApiDocumentation\PluginRegistry; class VersioningHeaderPlugin implements Plugin, OperationTransformer { public function __construct( private string $headerName = 'X-API-Version', private array $supportedVersions = ['2024-01-01', '2025-01-01'], private ?string $defaultVersion = null, ) { $this->defaultVersion ??= end($this->supportedVersions) ?: null; } public function name(): string { return 'versioning-header'; } public function boot(PluginRegistry $registry): void { $registry->addOperationTransformer($this, 25); } public function priority(): int { return 25; } public function transform(array $operation, AnalysisContext $ctx): array { // Only add to routes that match your versioned API prefix if (! str_starts_with($ctx->route->uri, 'api/')) { return $operation; } $operation['parameters'] ??= []; $operation['parameters'][] = [ 'name' => $this->headerName, 'in' => 'header', 'required' => false, 'description' => "API version. Defaults to `{$this->defaultVersion}` if omitted.", 'schema' => [ 'type' => 'string', 'enum' => $this->supportedVersions, 'default' => $this->defaultVersion, ], ]; return $operation; } }
Register with custom configuration:
// config/api-documentation.php 'plugins' => [ new App\Documentation\VersioningHeaderPlugin( headerName: 'X-API-Version', supportedVersions: ['2024-01-01', '2024-07-01', '2025-01-01'], ), ],
Cache Control Headers
Detects caching middleware and documents conditional request headers (ETag, If-None-Match).
<?php namespace App\Documentation; use JkBennemann\LaravelApiDocumentation\Contracts\OperationTransformer; use JkBennemann\LaravelApiDocumentation\Contracts\Plugin; use JkBennemann\LaravelApiDocumentation\Data\AnalysisContext; use JkBennemann\LaravelApiDocumentation\PluginRegistry; class CacheHeaderPlugin implements Plugin, OperationTransformer { /** * Middleware names that indicate the response supports caching. */ private const CACHE_MIDDLEWARE = [ 'cache.headers', 'etag', 'last-modified', ]; public function name(): string { return 'cache-headers'; } public function boot(PluginRegistry $registry): void { $registry->addOperationTransformer($this, 20); } public function priority(): int { return 20; } public function transform(array $operation, AnalysisContext $ctx): array { // Only apply to GET requests with caching middleware if (strtoupper($ctx->route->httpMethod()) !== 'GET') { return $operation; } $cacheMiddleware = $this->detectCacheMiddleware($ctx); if ($cacheMiddleware === null) { return $operation; } // Add conditional request headers $operation['parameters'] ??= []; $operation['parameters'][] = [ 'name' => 'If-None-Match', 'in' => 'header', 'required' => false, 'description' => 'ETag value from a previous response. Returns 304 if unchanged.', 'schema' => ['type' => 'string'], ]; // Document the 304 response $operation['responses']['304'] = [ 'description' => 'Not Modified — the resource has not changed since the last request.', ]; // Add cache-related response headers to the 200 response if (isset($operation['responses']['200'])) { $operation['responses']['200']['headers'] = array_merge( $operation['responses']['200']['headers'] ?? [], [ 'ETag' => [ 'description' => 'Entity tag for cache validation.', 'schema' => ['type' => 'string'], ], 'Cache-Control' => [ 'description' => 'Cache directives for the response.', 'schema' => ['type' => 'string'], 'example' => $this->buildCacheControlExample($cacheMiddleware), ], ], ); } return $operation; } private function detectCacheMiddleware(AnalysisContext $ctx): ?string { foreach ($ctx->route->middleware as $middleware) { $name = explode(':', $middleware)[0]; if (in_array($name, self::CACHE_MIDDLEWARE, true)) { return $middleware; } } return null; } private function buildCacheControlExample(string $middleware): string { // Parse cache.headers:public;max_age=3600 format if (str_starts_with($middleware, 'cache.headers:')) { $directives = substr($middleware, 14); return str_replace(['_', ';'], ['-', ', '], $directives); } return 'public, max-age=3600'; } }
Integrations
Spatie Laravel Data
When spatie/laravel-data is installed, Data objects used as controller method parameters are automatically documented:
class CreateUserData extends Data { public function __construct( public string $name, public string $email, public ?string $bio = null, ) {} } // Automatically generates request body schema with name (required), email (required), bio (nullable) public function store(CreateUserData $data): UserResource {}
Spatie Laravel Query Builder
When spatie/laravel-query-builder is installed, allowed filters, sorts, and includes are extracted as query parameters:
$users = QueryBuilder::for(User::class) ->allowedFilters(['name', 'email', 'status']) ->allowedSorts(['name', 'created_at']) ->allowedIncludes(['posts', 'profile']) ->paginate(); // Generates: filter[name], filter[email], filter[status], sort (enum), include (enum), page, per_page
Laravel Actions
When lorisleiva/laravel-actions is installed, Action classes with the AsController trait are analyzed:
class CreateUser { use AsAction; use AsController; public function rules(): array { return ['name' => 'required|string', 'email' => 'required|email']; } public function handle(string $name, string $email): User {} }
JSON:API (timacdonald/json-api)
When timacdonald/json-api is installed, JSON:API resources produce proper data/attributes/relationships response schemas.
CI/CD Integration
Generate on Deploy
php artisan api:generate --format=json
Block Breaking Changes
# Store current spec before changes cp storage/app/public/api-documentation.json /tmp/old-spec.json # Generate new spec php artisan api:generate # Compare - fails with exit code 1 if breaking changes detected php artisan api:diff /tmp/old-spec.json storage/app/public/api-documentation.json --fail-on-breaking
Validate Spec Quality
php artisan api:lint
# Returns non-zero exit code if errors are found
Generate with Captures in CI
DOC_CAPTURE_MODE=true php artisan test
php artisan api:generate
Security
The package is designed with security as a priority:
- Production safe: The capture middleware is gated behind environment checks and will never run in production, even if
DOC_CAPTURE_MODEis accidentally set - Sensitive data redaction: Passwords, tokens, API keys, credit card numbers, and SSNs are automatically redacted in captured examples
- No runtime overhead: The package only runs during documentation generation (
api:generate) and optionally during testing (capture mode). It adds zero overhead to production request handling
Please review our security policy on how to report security vulnerabilities.
Credits
License
The MIT License (MIT). Please see License File for more information.