bambamboole / spectacular
OpenAPI and AsyncAPI tooling for Laravel applications.
Requires
- php: ^8.4
- dedoc/scramble: ^0.13.30
- illuminate/support: ^13.0
- league/openapi-psr7-validator: ^0.24
- nyholm/psr7: ^1.8
- spatie/laravel-query-builder: ^7.0
- symfony/psr-http-message-bridge: ^8.0
Requires (Dev)
- bambamboole/extended-testbench: ^0.6
- bambamboole/laravel-webhooks: >=0.3 <1.0.0
- larastan/larastan: ^3.10
- laravel/pint: ^1.29
- pestphp/pest: ^5.0
- pestphp/pest-plugin-laravel: ^5.0
- pestphp/pest-plugin-phpstan: ^5.0
- rector/rector: ^2.0
- spatie/laravel-data: ^4.23
- spatie/laravel-model-states: ^2.14
Suggests
- bambamboole/laravel-webhooks: Document webhook events alongside broadcasts in the generated AsyncAPI (>=0.3).
- lattice-php/api-reference: Render the generated OpenAPI documents as a browsable API reference (Lattice component, ^0.42).
- spatie/laravel-data: Document the request body of actions taking a Data object, descriptions included (^4.23).
- spatie/laravel-model-states: Document model state properties as string enum schemas in the generated OpenAPI (^2.0).
This package is auto-updated.
Last update: 2026-08-14 11:55:37 UTC
README
OpenAPI and AsyncAPI tooling for Laravel applications.
Spectacular gives you two things from the code you already write:
- OpenAPI — Scramble extensions that document spatie/laravel-query-builder filters, sorts, includes and sparse fieldsets, plus pagination parameters, directly from your controller actions — no annotations required.
- AsyncAPI — a generator that turns your Laravel broadcast events into an AsyncAPI 3.0 document, inferring channels and message payloads from the event class itself.
Requirements
- PHP 8.4+
- Laravel 13+
dedoc/scramble^0.13.30(for the OpenAPI extensions)spatie/laravel-query-builder^7.0(for the query-builder extension)
Installation
composer require bambamboole/spectacular
The service provider is auto-discovered. Publish the config file if you want to customise the defaults:
php artisan vendor:publish --tag=spectacular-config
This writes config/spectacular.php.
OpenAPI
Spectacular ships Scramble operation extensions for query builder
parameters, pagination and its own documentation attributes, along with transformers that document validation errors,
rate limits, laravel-data request bodies and the info object. The service provider registers them for you; add your own
through Scramble's native scramble.extensions config.
Query builder parameters
Any action that builds a Spatie\QueryBuilder\QueryBuilder chain is inspected statically, and the allowed operations
become documented query parameters:
use Spatie\QueryBuilder\AllowedFilter; use Spatie\QueryBuilder\QueryBuilder; class UsersController { public function __invoke(Request $request): AnonymousResourceCollection { $users = QueryBuilder::for(User::class) ->allowedFilters('name', AllowedFilter::exact('email')) ->allowedSorts('name', 'created_at') ->allowedIncludes('roles') ->paginate($request->integer('per_page', 15)); return UserResource::collection($users); } }
Produces filter[name], filter[email], sort and include parameters — with enums, descriptions and the correct
array styling — plus page and per_page from the extension below. Spectacular does not document allowedFields()
because it limits selected database columns rather than the fields serialized by a standard Laravel JSON resource.
Laravel JsonApiResource sparse fieldsets are handled separately by Scramble.
Filter, sort and include names honour the relevant config/query-builder.php settings, so a customised query-builder
config is reflected in the generated document.
Filter schemas and matching
The AllowedFilter factory a filter was declared with decides both how it is described and whether the model can type
it. exact, belongsTo and operator compare a whole column value, so the schema comes from the model named in
QueryBuilder::for():
| The column | Documented as |
|---|---|
| A backed enum cast | string or integer with the case values as enum |
A boolean cast |
boolean |
An integer cast |
integer |
A float, double or decimal:n cast |
number |
A date or datetime cast |
string with format: date / date-time |
| The model's own key | Its key type, format: uuid with HasUuids |
A *_id naming a BelongsTo relation |
The related model's key |
AllowedFilter::trashed() documents the with, only and empty values it accepts. Text matching (partial,
beginsWith, endsWith) stays a string whatever the column holds, because a client sends a fragment rather than a
value. A filter whose semantics Spectacular cannot know (callback, custom) and a chain opened with something other
than a model class stay untyped.
Pagination parameters
paginate(), simplePaginate() and cursorPaginate() on a query-builder chain are documented automatically:
paginate/simplePaginate→ apageinteger parameter (minimum1).cursorPaginate→ acursorstring parameter.- A
per_page-style parameter is derived from a$request->integer('per_page', 15)(orinput/query) argument, including its default.
Custom page/cursor names (pageName, cursorName) and the per-page key are read from the call arguments.
To let clients choose between pagination modes, use Spectacular's query builder:
use Bambamboole\Spectacular\PaginationMode; use Bambamboole\Spectacular\QueryBuilder; return UserResource::collection( QueryBuilder::for(User::class)->apiPaginate( modes: [PaginationMode::Default, PaginationMode::Cursor], max: 100, ), );
Available modes are Default, Simple and Cursor. Modes default to [PaginationMode::Default]; when several are
declared, the first is the default and clients select another with the x-pagination header. The API reference renders
that header as a select and includes it in generated and live requests. OpenAPI responses use titled anyOf branches
for each declared mode.
per_page defaults to the model's page size. Supplied integers are clamped between 1 and max, which defaults to
100.
Authentication modes
Inside your own app the reference can borrow a token from the session. A public reference cannot, so the document has to state how a reader is meant to authenticate. Declare the modes in config instead of assembling scheme objects:
// config/spectacular.php 'openapi' => [ 'security' => [ 'middleware' => ['auth:api'], 'schemes' => [ 'bearer' => [ 'type' => 'http', 'scheme' => 'bearer', 'description' => 'A personal access token.', ], 'oauth2' => [ 'type' => 'oauth2', 'flows' => [ 'authorizationCode' => [ 'authorization_url' => '/oauth/authorize', 'token_url' => '/oauth/token', 'scopes' => ApiScopes::class, ], 'clientCredentials' => ['token_url' => '/oauth/token'], ], ], ], ], ],
Each entry becomes an entry in components.securitySchemes and a document-level requirement; several entries read as
alternatives, so a client picks one. Supported types are http, apiKey, oauth2, openIdConnect and mutualTLS.
Relative URLs are resolved against the app URL, absolute ones are kept — handy when authorization lives on a separate
identity host.
Scopes are usually derived from the app itself, which a cached config file cannot hold. Besides a literal
['scope' => 'description'] map, scopes accepts an invokable class-string that is resolved through the container and
returns one:
final class ApiScopes { public function __construct(private PermissionRepository $permissions) {} /** @return array<string, string> */ public function __invoke(): array { return $this->permissions->apiScopes(); } }
A route carrying none of the middleware patterns is documented as public (security: []). Operations that already
declare their own requirement — per-endpoint scopes, for instance — are left untouched. Leave schemes empty to keep
documenting security yourself.
Validation errors
Scramble infers a 422 only where it can see validation happen inside the controller — a validate() call or a Form
Request. Spectacular documents one on every POST, PUT and PATCH operation instead, referencing a single
ValidationException response component with Laravel's message and errors body. An operation that already documents
a 422 is left untouched.
Rate limits
Throttling happens in middleware, which no controller body reveals — without it an endpoint reads as if a client could
call it as often as it likes. A route carrying one of the configured middleware patterns documents its request budget on
every success response, plus a shared ThrottleRequestsException response for an exhausted limit:
// config/spectacular.php 'rate_limiting' => [ 'middleware' => ['throttle', 'throttle:*'], 'headers' => [ 'X-RateLimit-Limit' => 'The maximum number of requests allowed in the current window.', 'X-RateLimit-Remaining' => 'The number of requests left in the current window.', ], 'exhausted_headers' => [ 'Retry-After' => 'Seconds to wait before sending another request.', 'X-RateLimit-Reset' => 'Seconds until the current window resets.', ], ],
The defaults describe what Laravel's own throttle middleware returns. An app throttling through a middleware of its
own adds that alias to middleware; one that sets a further header while the limit holds moves it from
exhausted_headers into headers. Header values are documented as integers. An operation that already documents a
429 is left untouched, and leaving middleware or headers empty keeps rate limits undocumented.
The info object
Scramble resolves the document title from scramble.ui.title and the version and description from scramble.info.
What OpenAPI offers beyond those three is only available here:
// config/spectacular.php 'info' => [ 'description' => 'What this API is for.', 'terms_of_service' => 'https://acme.test/terms', 'contact' => ['name' => 'API support', 'email' => 'api@acme.test'], 'license' => ['name' => 'MIT', 'identifier' => 'MIT'], ],
Anything set here wins over what Scramble resolved; anything left out keeps it. A licence needs a name to be
documented at all, and OpenAPI allows an SPDX identifier or a url but not both — given both, the url is dropped.
laravel-data request bodies
When spatie/laravel-data is installed, an action that takes a Data object gets its request body documented — without
it such endpoints appear to accept nothing at all, because the validation happens while the container resolves the
argument rather than in the controller body.
final class StoreArticleData extends Data { public function __construct( /** Headline of the article. */ public string $title, /** Teaser shown in listings. */ public ?string $summary = null, /** Whether the article is publicly visible. Defaults to false. */ #[MapInputName('is_published')] public bool $isPublished = false, ) {} }
The docblock above a promoted property becomes that field's description, so a payload is described where it is
declared instead of in a per-endpoint attribute.
Which fields are mandatory is taken from the properties themselves, not from the generated rules: a property carrying a
default or a nullable type may be left out, even though its rules say required once it is present. An optional object
holding a mandatory field also stays optional — only title is required above, and an omitted summary is not an
error. A Data class that nests itself is expanded once and then documented as an unconstrained array, which keeps a
tree-shaped payload from recursing forever.
A Data class declaring its own rules() method is left to Scramble, which reads that method directly.
State transition endpoints
When spatie/laravel-model-states is installed, a templated transition route such as
PATCH /api/orders/{order}/transition-to/{state} is fanned out into one documented operation per reachable target
state — …/transition-to/paid, …/transition-to/cancelled — each with a distinct summary, the source states that
allow it, a shared 409 Conflict response, and exactly the request body its transition expects. The base state class
opts in with a marker attribute; there is nothing to configure:
#[StateEndpoint] abstract class OrderState extends State { public static function config(): StateConfig { return parent::config() ->allowTransition(Pending::class, Paid::class) ->allowTransition(Pending::class, Cancelled::class, MarkAsCancelled::class); } }
A documented route qualifies when its URI carries a {state} parameter and its action binds a model that casts a field
to the annotated state class — path and HTTP method come from the route itself. The attribute's optional label (the
noun used in summaries and descriptions) defaults to the state class basename without its State suffix, lowercased.
A transition takes a request body by declaring a laravel-data object in its custom transition constructor after the
model; the documented operation then requires exactly that body, described like any other data payload. Transitions
without a data class document without a request body. All source states of one target must agree on the payload, since
a single operation cannot carry a different body per current state.
final class MarkAsCancelled extends Transition { public function __construct( private readonly Order $order, private readonly CancelOrderData $data, ) {} }
The runtime side ships too: TransitionModelState takes the payload as a plain array — no HTTP coupling, so jobs and
actions can drive transitions the same way. It resolves the target state from its morph name, validates the payload
against the transition's data class when one exists (rejecting undeclared keys, and any payload on transitions that
take none), executes the transition inside a database transaction, and throws StateTransitionDenied — self-rendering
as the documented 409 with current_state, requested_state, and allowed_states — when the current state does not
allow the move. A controller needs one line:
Route::patch('orders/{order}/transition-to/{state}', OrderTransitionsController::class); final class OrderTransitionsController { public function __invoke(Order $order, string $state, Request $request, TransitionModelState $transition): OrderResource { return new OrderResource($transition->handle($order, 'status', $state, $request->json()->all())); } }
Both messages the runtime produces are translatable under the spectacular::states namespace
(php artisan vendor:publish --tag=spectacular-lang).
Documentation attributes
Three attributes document a payload field, a parameter and an endpoint. Each takes a tooltip: a short piece of HTML,
links included, emitted as x-tooltip next to the description it belongs to, for an API reference to render beside
the field.
#[SpecProperty] documents a laravel-data payload field:
use Bambamboole\Spectacular\Attributes\SpecProperty; final class StoreCategoryData extends Data { public function __construct( #[SpecProperty( description: 'Display name of the category.', tooltip: 'Shown in navigation. Read the <a href="/docs/categories">category guide</a>.', )] public string $name, /** Publication state of the category. Drafts stay hidden. */ #[SpecProperty(tooltip: 'Only <code>published</code> categories appear in the storefront.')] public CategoryStatus $status = CategoryStatus::Draft, ) {} }
Docblocks stay a valid way to describe a field, and the two mix freely: status above keeps its docblock description
and gains a tooltip. When a property carries both a docblock and a description in the attribute, the attribute wins.
#[SpecParameter] documents a single parameter of an endpoint, selected by name. It is repeatable, and it reaches both
path parameters and the query parameters Spectacular generates itself (filter[…], sort, include, page,
per_page, cursor) — which is what it takes to describe a filter in your own words:
use Bambamboole\Spectacular\Attributes\SpecParameter; #[SpecParameter( 'filter[status]', description: 'Filter by publication state.', tooltip: 'One of <code>draft</code>, <code>published</code> or <code>archived</code>.', )] #[SpecParameter('user', description: 'Identifier of the user to load.')] public function __invoke(Request $request): AnonymousResourceCollection
A default can be documented alongside, and it lands on the parameter's schema. A path parameter needs no
#[PathParameter] of Scramble's next to it.
#[SpecEndpoint] adds a tooltip to the operation. Its title and description stay with Scramble's own #[Endpoint]:
use Bambamboole\Spectacular\Attributes\SpecEndpoint; #[SpecEndpoint(tooltip: 'Creating a category requires the <code>categories:write</code> scope.')] public function __invoke(StoreCategoryData $data): CategoryResource
Response resources are not covered: a JsonResource::toArray() describes its fields through docblocks, and a PHP
attribute cannot attach to a key of an array literal.
Generating the document
php artisan spectacular:openapi # print to stdout php artisan spectacular:openapi --path=openapi.json php artisan spectacular:openapi --pretty=false # compact JSON
The command renders the same document Scramble produces, so all of Scramble's own configuration applies.
AsyncAPI
Annotate the broadcast events you want documented with the #[Message] attribute. Spectacular scans the configured
paths for events implementing ShouldBroadcast / ShouldBroadcastNow that carry the attribute:
use Bambamboole\Spectacular\AsyncApi\Attributes\Message; use Illuminate\Broadcasting\PrivateChannel; use Illuminate\Contracts\Broadcasting\ShouldBroadcast; #[Message( summary: 'User notification was created', description: 'Sent when a user receives a notification.', tags: ['notifications'], )] final class UserNotificationBroadcast implements ShouldBroadcast { public function __construct(public int $userId) {} public function broadcastOn(): array { return [new PrivateChannel('users.'.$this->userId)]; } public function broadcastAs(): string { return 'user.notification.created'; } /** * @return array{notificationId: int, team: string, sentAt: \Carbon\CarbonImmutable, status: BroadcastStatus} */ public function broadcastWith(): array { return [/* ... */]; } }
From an event, Spectacular derives:
- Channels — from the
#[Message(channels: [...])]argument, or inferred by invokingbroadcastOn()when the attribute omits them. Channel type (public,private,presence,private-encrypted) is detected from the name. - Message name — from
broadcastAs()when present, otherwise the fully-qualified class name. - Payload schema — from the
broadcastWith()@returnPHPDoc (array shapes,list<>,array<string, T>, nullable and union types are all understood). WhenbroadcastWith()is absent, the event's public properties are used, mapping scalars, enums,DateTimeInterfaceand nested objects to JSON Schema.
The #[Message] attribute
#[Message(
channels: [], // explicit channel names; inferred from broadcastOn() when empty
title: null, // human-friendly message title
summary: null, // short message summary
description: null, // longer description
tags: [], // AsyncAPI message tags
payload: null, // reference an external payload schema ($ref) instead of inferring
)]
Broadcast notifications
Use #[BroadcastNotification] on Laravel notification classes that are delivered through the broadcast channel:
use Bambamboole\Spectacular\AsyncApi\Attributes\BroadcastNotification; use Illuminate\Notifications\Messages\BroadcastMessage; use Illuminate\Notifications\Notification; #[BroadcastNotification( notifiables: [User::class], title: 'Invoice paid', summary: 'Sent to users when an invoice is paid.', tags: ['billing'], )] final class InvoicePaidNotification extends Notification { public function via(object $notifiable): array { return ['broadcast']; } /** * @return BroadcastMessage&object{data: array{invoiceId:int, amount:int}} */ public function toBroadcast(object $notifiable): BroadcastMessage { return new BroadcastMessage([ 'invoiceId' => 123, 'amount' => 4999, ]); } }
Spectacular infers notification channels from the notifiables classes. If a notifiable exposes
receivesBroadcastNotificationsOn(), that value is used; otherwise the channel defaults to a private placeholder such
as private-App.Models.User.{userId}. Pass explicit channels when notifications use custom or dynamic broadcast
channels that cannot be inferred.
Webhook events
Webhook documentation is optional and needs bambamboole/laravel-webhooks
(install separately); without it, the AsyncAPI document simply contains no webhook channel. Use its #[WebhookEvent]
attribute on outbound webhook event classes you want listed in the AsyncAPI document:
use Bambamboole\LaravelWebhooks\Attributes\WebhookEvent; #[WebhookEvent( name: 'invoice.paid', title: 'Invoice paid', summary: 'Sent when an invoice is paid.', tags: ['billing'], )] final class InvoicePaidWebhook { public function __construct(public int $invoiceId, public int $amount) {} /** * @return array{invoiceId:int, amount:int} */ public function webhookPayload(): array { return [ 'invoiceId' => $this->invoiceId, 'amount' => $this->amount, ]; } }
Laravel Webhooks owns runtime event discovery, subscriptions, delivery, signing, retries, caching, and delivery history. Follow the Laravel Webhooks documentation to configure those runtime concerns.
Spectacular limits its webhook role to the generated AsyncAPI channel, message metadata, envelope schema, configured
headers, and the spectacular:asyncapi command.
Laravel extensions
By default the document includes x-laravel-* extension fields (channel type, source event class, whether it
broadcasts now). Disable them with laravel_extensions => false in the config.
Configuration
// config/spectacular.php 'asyncapi' => [ 'version' => '3.0.0', 'default_content_type' => 'application/json', 'info' => [ 'title' => env('APP_NAME', 'Laravel').' AsyncAPI', 'version' => env('APP_VERSION', '0.0.1'), ], 'laravel_extensions' => true, 'scan_paths' => [ app_path('Events'), ], 'webhooks' => [ 'channel' => [ 'key' => 'webhooks', 'address' => '{webhookUrl}', ], 'headers' => [ 'Content-Type' => ['type' => 'string', 'enum' => ['application/json']], 'Signature' => ['type' => 'string'], 'Timestamp' => ['type' => 'integer'], ], ], ],
Generating the document
php artisan spectacular:asyncapi # print to stdout php artisan spectacular:asyncapi --path=asyncapi.json php artisan spectacular:asyncapi --pretty=false # compact JSON
Displaying docs with Lattice
The interactive API reference viewer lives in lattice-php/api-reference,
a first-party Lattice component package. It renders any generated OpenAPI document as a
browsable reference with a request playground — see the
docs page with a live demo:
composer require lattice-php/api-reference
use Dedoc\Scramble\Generator; use Illuminate\Support\Facades\Cache; use Lattice\ApiReference\ApiReference; use Lattice\Core\Attributes\AsPage; use Lattice\Http\Page; use Lattice\Ui\PageSchema; #[AsPage(route: 'docs', name: 'docs', middleware: ['auth'])] final class ApiDocsPage extends Page { public function render(PageSchema $schema, Generator $generator): PageSchema { $document = Cache::rememberForever( 'spectacular.openapi', fn () => $generator(), ); return $schema->schema([ApiReference::make()->spec($document)]); } }
Scramble's generator only needs phpstan/phpdoc-parser and nikic/php-parser at runtime (PHPStan itself is a
require-dev package of Scramble), so generating the document on request works in production. It still walks your
whole app via reflection and AST parsing though, so cache the result rather than regenerating it per request —
Cache::forget('spectacular.openapi') on deploy, or a shorter TTL, both work. Gate the page behind middleware (or
an env check) if the reference shouldn't be public.
Testing
composer test # Pest composer check # Pint (test), PHPStan, Pest — mirrors CI
The package is developed with Orchestra Testbench; the workbench/ app
provides the routes and events exercised by the test suite.
License
Spectacular is open-sourced software licensed under the MIT license.
