OpenAPI¶
Namespace Keel\OpenApi. The routes, the #[FromQuery] and #[FromBody] DTOs, the validation
rules, #[Status], return types, #[Auth] and #[RateLimit] already describe the API; the
generator turns them into an OpenAPI 3.1 document without a second description to keep in sync.
Serving the document¶
Add OpenApiMiddleware to HttpConfig and, optionally, config/openapi.php:
return new OpenApiConfig(
title: 'Orders API',
version: '2.3.0',
description: 'Internal order management',
servers: [['url' => 'https://api.example.com']],
specPath: '/openapi.json', // null disables
docsPath: '/docs', // HTML reference (Scalar) built on the spec; null disables
problemDetails: true, // document 400/401/403/429 Problem Details responses
docsScript: OpenApiConfig::DEFAULT_DOCS_SCRIPT, // the Scalar script the docs page loads, pinned to a version
);
The document is generated on the first request and kept for the life of the process: php-fpm
and the built-in server rebuild it per request, workers on restart (keel:serve --watch).
keel:openapi prints the same JSON, keel:openapi --output=public/openapi.json writes it, for
committing or feeding to client generators.
Both paths are answered before routing, so #[Auth] cannot protect them; restrict them in the
reverse proxy or disable them in production if the API is not public. The docs page loads the
Scalar reference UI from jsDelivr, pinned to a version; set docsScript to another version or
to a copy you host.
What is derived¶
| Source | In the document |
|---|---|
route path /users/{id:\d+} |
path /users/{id}, a required path parameter typed from the handler argument, pattern from the constraint; optional segments (/users[/{id}]) give one path per variant, each with its own parameters; #[Redirect] routes are left out |
#[FromQuery] Filter $f |
one query parameter per constructor parameter of the DTO; required when no default and not nullable |
#[FromQuery] int $page = 1 |
one query parameter |
#[FromBody] CreateUser $c |
requestBody with a $ref to the CreateUser component schema |
| DTO constructor parameter | property typed from the signature: scalars, nullable (type: [string, null]), unions (oneOf), backed and unit enums (enum), nested DTOs ($ref), DateTimeInterface (date-time), arrays with element types from @param list<Item> or #[Field(items:)] |
| validation rule | Length → minLength/maxLength, Range → minimum/maximum, Count → minItems/maxItems, Pattern/Alnum → pattern, Email → format: email, Url → format: uri, Uuid → format: uuid, Hostname → format: hostname, Ip(4)/Ip(6) → format: ipv4/ipv6, Date → format: date or date-time, Choice → enum, Unique → uniqueItems, Each → the item rules under items, NotBlank → required |
#[Status(201)] and the return type |
the success response: a DTO → $ref, list<Dto> from @return → array of $ref, string → text/html, void/null → 204, ResponseInterface → no schema |
Page return type |
the pagination envelope (items, page, perPage, total, pages) with the element type from @return Page<Dto> or #[Response(type: Page::class, items: Dto::class)] |
#[Auth], #[Can] |
security with every configured authenticator's scheme, plus 401 (and 403 with roles or an ability) |
#[RateLimit] |
a 429 response |
#[Deprecated] |
deprecated: true, and x-sunset with the sunset date |
#[Idempotent] |
the Idempotency-Key header parameter (required with required: true), 409 and 422 responses |
#[BodyLimit] |
413 when it sets a size, 415 when it lists media types |
| body or parameters present | a 400 response with the Problem schema |
Operation ids come from the route name, else <Controller><Method> (userShow); tags from the
controller class name without Controller. Ids are unique in the document: a route with several
HTTP methods gets one per method (syncGet, syncPost), and a second route that would reuse an
id (two UserController::show() in different namespaces) gets a numeric suffix (userShow2).
Give those an explicit #[Operation(operationId:)] when the generated names matter to a client
generator. Element types from @param list<Item> and @return list<Item> are resolved like PHP
would: fully qualified names, then the file's use imports (aliases included), then the
declaring namespace.
Saying more¶
#[Prefix('/users')]
#[Operation(tags: ['Users'], description: 'User accounts')] // class-level defaults
final class UserController
{
#[Get('/{id}')]
#[Operation(summary: 'Fetch one user', operationId: 'getUser')]
#[Response(200, 'The user', UserView::class)]
#[Response(404, 'No such user')]
public function show(int $id): ResponseInterface { /* ... */ }
#[Get('/export')]
#[Operation(deprecated: true)]
public function export(): string { /* ... */ }
#[Get('/internal')]
#[Hidden] // left out of the document
public function internal(): array { /* ... */ }
}
final readonly class CreateUser
{
/** @param list<Address> $addresses */
public function __construct(
#[NotBlank, Length(max: 80)]
#[Field(description: 'Display name', example: 'Ann')]
public string $name,
#[Field(items: Address::class)] // or the @param above
public array $addresses = [],
#[Field(format: 'uuid', deprecated: true)]
public ?string $legacyId = null,
) {}
}
#[Operation]: summary, description, tags, operationId, deprecated. On the class it sets defaults for every route; method-level values win per field.#[Response(status, description, type, items, contentType)], repeatable: documented responses are merged over the derived ones, so#[Response(404)]alone keeps the derived 200; declaring any 2xx replaces the derived success response.typeis a DTO class, a scalar type name orarray.#[Field]on a DTO constructor parameter or a#[FromQuery]scalar: description, example, format, element type of an array, deprecated.#[Hidden]on a method or a class leaves routes out.
SchemaBuilder and Generator are public: generate a document for a subset of routes
(new Generator(Router::fromControllers(...))) or build schemas for your own tooling.