docs/guide/spec-attributes.md
::: warning Beta Spec attributes are feature-complete but still in beta. The API may evolve based on feedback before being promoted to the default mode in a future major version. :::
Spec attributes are a new way to annotate your PHP code for OpenAPI generation. They live in the OpenApi\Spec namespace and are designed as simple, typed data containers โ no serialization logic, no mutation, just clean PHP 8.1+ attributes.
use OpenApi\Spec as OA;
This replaces the classic use OpenApi\Attributes as OA;. The OA alias keeps your code familiar.
A minimal working API with spec attributes:
use OpenApi\Spec as OA;
#[OA\OpenApi(version: '3.1.0')]
#[OA\Info(title: 'My API', version: '1.0.0')]
#[OA\Server(url: 'https://api.example.com')]
class OpenApiSpec {}
use OpenApi\Spec as OA;
#[OA\Schema]
class Pet
{
#[OA\Property]
public int $id;
#[OA\Property]
public string $name;
#[OA\Property]
public ?string $tag;
}
use OpenApi\Spec as OA;
class PetController
{
#[OA\Operation\Get(path: '/pets', operationId: 'listPets', tags: ['pets'])]
#[OA\Response(
response: 200,
description: 'A list of pets',
content: [new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(type: 'array', items: new OA\Schema(ref: Pet::class)),
)],
)]
public function list() {}
#[OA\Operation\Get(path: '/pets/{petId}', operationId: 'showPet', tags: ['pets'])]
#[OA\Parameter(name: 'petId', in: 'path', required: true)]
#[OA\Response(
response: 200,
description: 'A single pet',
content: [new OA\MediaType(mediaType: 'application/json', schema: new OA\Schema(ref: Pet::class))],
)]
public function show(int $petId) {}
}
Generate the spec:
use OpenApi\Builder;
use OpenApi\Builder\Mode;
$result = (new Builder())
->setMode(Mode::SPEC)
->addSource('src/')
->build();
echo $result->toYaml();
Or via CLI:
./vendor/bin/openapi src/ --mode spec
Place #[OA\Schema] on a class to define a schema component. Properties are declared with #[OA\Property] on class properties.
use OpenApi\Spec as OA;
#[OA\Schema(title: 'User', required: ['email'])]
class User
{
#[OA\Property]
#[OA\Schema(format: 'int64')]
public int $id;
#[OA\Property]
public string $email;
#[OA\Property]
public ?string $name;
}
Types, formats, and nullability are inferred from PHP type declarations by the Types augmenter. You only need to specify #[OA\Schema(...)] on a property when you want to override the inferred values (e.g. format, description, example).
In spec mode, OA\Schema is standalone โ it doesn't inherit from anything. This means you can stack #[OA\Property] and #[OA\Schema] on the same target:
#[OA\Property(property: 'status')]
#[OA\Schema(type: 'string', enum: ['active', 'inactive'])]
public string $status;
#[OA\Property] declares that this is a property of the parent schema. #[OA\Schema] provides the property's type definition. When no explicit #[OA\Schema] is present, the type is inferred from the PHP declaration.
Operations map to HTTP methods on API endpoints. Spec mode provides typed subclasses for each HTTP method:
#[OA\Operation\Get(path: '/pets')]
#[OA\Operation\Post(path: '/pets')]
#[OA\Operation\Put(path: '/pets/{id}')]
#[OA\Operation\Delete(path: '/pets/{id}')]
#[OA\Operation\Patch(path: '/pets/{id}')]
#[OA\Operation\Head(path: '/pets')]
#[OA\Operation\Options(path: '/pets')]
You can also use the base class with an explicit method:
#[OA\Operation(path: '/pets', method: 'get')]
Responses and parameters can be nested inside the operation or placed as separate attributes on the same method:
// Nested
#[OA\Operation\Get(path: '/pets/{id}', responses: [
new OA\Response(response: 200, description: 'OK'),
new OA\Response(response: 404, description: 'Not found'),
])]
public function show(int $id) {}
// Flat (equivalent โ merged automatically)
#[OA\Operation\Get(path: '/pets/{id}')]
#[OA\Response(response: 200, description: 'OK')]
#[OA\Response(response: 404, description: 'Not found')]
public function show(int $id) {}
Parameters can be placed directly on method arguments:
#[OA\Operation\Get(path: '/pets/{petId}')]
public function show(
#[OA\Parameter(name: 'petId', in: 'path', required: true)]
int $petId
) {}
Typed parameter subclasses reduce boilerplate:
#[OA\Operation\Get(path: '/pets/{petId}')]
public function show(
#[OA\Parameter\Path(name: 'petId')]
int $petId
) {}
Available subclasses: OA\Parameter\Path, OA\Parameter\Query, OA\Parameter\Header, OA\Parameter\Cookie.
#[OA\PathItem] on a class groups operations with shared configuration. It enables prefix composition via class inheritance and pushes shared metadata down to operations.
use OpenApi\Spec as OA;
#[OA\PathItem(prefix: '/api/v1')]
class BaseController {}
#[OA\PathItem(prefix: '/users', tags: ['Users'])]
class UserController extends BaseController
{
#[OA\Operation\Get(path: '/')]
public function list() {}
#[OA\Operation\Get(path: '/{id}')]
public function show(int $id) {}
}
This produces paths /api/v1/users/ and /api/v1/users/{id}, both tagged with Users.
| Feature | Description |
|---|---|
prefix | Composable path prefix, inherited via class hierarchy |
tags | Cloned to all operations that don't declare their own |
security | Cloned to all operations that don't declare their own |
responses | Cloned to all operations (e.g. shared error responses) |
parameters | Emitted as path-level parameters in the output |
summary / description | Emitted at path level |
servers | Emitted at path level |
OA\MediaType\Json and OA\MediaType\Xml are the corresponding versions of the classic OA\JsonContent and OA\XmlContent, respectively.
They do work pretty much the same, however since they are not inheriting from OA\Schema, they only support a limited (most common) set of schema attributes.
Still useful even when nesting OA\Schema as the media type is prefilled either way.
If a nested OA\Schema is set, the custom attributes are ignored.
// Without shortcut
#[OA\Response(response: 200, content: [
new OA\MediaType(mediaType: 'application/json', schema: new OA\Schema(type: 'array', items: new OA\Schema(ref: Pet::class))),
])]
// With shortcut
#[OA\Response(response: 200, content: [new OA\MediaType\Json(type: 'array', items: new OA\Schema(ref: Pet::class))])]
#[OA\Schema\Items] is the equivalent to the classic OA\Items attribute. Similar to OA\MediaType\Json and Xml it is a shortcut that allows to skip the outer OA\Schema(type: 'array'). The Shortcuts augmenter wraps it into OA\Schema(type: 'array', items: ...) automatically.
// Without shortcut
#[OA\Property]
#[OA\Schema(type: 'array', items: new OA\Schema(ref: Tag::class))]
public array $tags;
// With shortcut
#[OA\Property]
#[OA\Schema\Items(ref: Tag::class)]
public array $tags;
Both the MediaType and Items shortcuts are resolved by the Shortcuts augmenter.
OA\Schema\AdditionalProperties is a typed alias for OA\Schema โ functionally identical but improves readability when declaring additionalProperties constraints:
#[OA\Schema(
type: 'object',
properties: [
new OA\Property(
property: 'errors',
schema: new OA\Schema(
type: 'object',
additionalProperties: new OA\Schema\AdditionalProperties(
type: 'array',
items: new OA\Schema\Items(type: 'string'),
),
),
),
],
)]
class ValidationErrors {}
#[OA\Components] is a class-level container for reusable definitions that cannot stand alone as root attributes โ primarily Parameters, Headers, Links, and Examples.
use OpenApi\Spec as OA;
#[OA\Components]
class SharedComponents
{
#[OA\Parameter(parameter: 'page', name: 'page', in: 'query')]
#[OA\Schema(type: 'integer', default: 1)]
public int $page;
#[OA\Parameter(parameter: 'per_page', name: 'per_page', in: 'query')]
#[OA\Schema(type: 'integer', default: 20)]
public int $perPage;
#[OA\Header(header: 'X-Rate-Limit', description: 'Requests remaining')]
#[OA\Schema(type: 'integer')]
public string $rateLimit;
}
These can then be referenced from operations via $ref:
#[OA\Operation\Get(path: '/users', parameters: [
new OA\Parameter(ref: '#/components/parameters/page'),
new OA\Parameter(ref: '#/components/parameters/per_page'),
])]
public function list() {}
::: tip When to use Components Schemas, PathItems, SecuritySchemes, and named Responses/RequestBodies are root attributes โ they can be declared directly on a class without a Components wrapper. Use Components only for types that can't stand alone (Parameter, Header, Link, Example). :::
Spec attributes support schema composition via PHP class hierarchy. The rules mirror PHP inheritance:
$ref to allOfuse OpenApi\Spec as OA;
#[OA\Schema]
class BaseModel
{
#[OA\Property]
public int $id;
#[OA\Property]
public string $createdAt;
}
#[OA\Schema]
class User extends BaseModel
{
#[OA\Property]
public string $email;
#[OA\Property]
public string $name;
}
Output for User:
User:
allOf:
- $ref: '#/components/schemas/BaseModel'
- type: object
properties:
email:
type: string
name:
type: string
Traits with #[OA\Schema] are composed via $ref in allOf. Traits without a schema have their properties merged inline.
#[OA\Schema]
trait HasTimestamps
{
#[OA\Property]
public string $createdAt;
#[OA\Property]
public string $updatedAt;
}
#[OA\Schema]
class Post
{
use HasTimestamps;
#[OA\Property]
public string $title;
}
Output for Post:
Post:
allOf:
- $ref: '#/components/schemas/HasTimestamps'
- type: object
properties:
title:
type: string
Security schemes use typed subclasses:
use OpenApi\Spec as OA;
#[OA\Security\Scheme\Http(securityScheme: 'bearerAuth', scheme: 'bearer')]
#[OA\Security\Scheme\ApiKey(securityScheme: 'apiKey', name: 'X-API-Key', in: 'header')]
#[OA\Security\Scheme\OAuth2(securityScheme: 'oauth2', flows: [
new OA\Flow\AuthorizationCode(
authorizationUrl: 'https://auth.example.com/authorize',
tokenUrl: 'https://auth.example.com/token',
scopes: ['read:pets' => 'Read pets', 'write:pets' => 'Write pets'],
),
])]
class OpenApiSpec {}
Apply security to operations:
#[OA\Operation\Get(path: '/pets', security: [
new OA\Security\Requirement(scheme: 'bearerAuth'),
])]
public function list() {}
Or apply globally via the OpenApi attribute or via PathItem (cloned to all operations).
Classic (OpenApi\Attributes) | Spec (OpenApi\Spec) |
|---|---|
use OpenApi\Attributes as OA; | use OpenApi\Spec as OA; |
#[OA\Get(path: '/pets')] | #[OA\Operation\Get(path: '/pets')] |
#[OA\JsonContent(...)] | new OA\MediaType\Json(...) - but with limited set of (OA\Schema) attributes only |
#[OA\PathParameter(...)] | #[OA\Parameter\Path(...)] |
#[OA\Items(...)] | #[OA\Schema\Items(...)] |
| Schema inherits from annotation base | Schema is standalone, stackable |
| Processors modify mutable annotation tree | Augmenters enrich immutable DTOs |
| Single serializer with version branches | Dedicated compiler per OpenAPI version |
Beyond the API changes listed above, spec mode produces slightly different output in some cases:
type: object on allOf schemasWhen a class-based schema uses allOf, spec mode emits an explicit type: object on the schema itself. Classic mode omits it.
#[OA\Schema(
schema: 'create-user',
allOf: [
new OA\Schema(ref: '#/components/schemas/abstract-user'),
new OA\Schema(required: ['name', 'email']),
]
)]
class CreateUser extends AbstractUser {}
Classic output:
create-user:
allOf:
- $ref: '#/components/schemas/abstract-user'
- required:
- name
- email
Spec output:
create-user:
type: object
allOf:
- $ref: '#/components/schemas/abstract-user'
- required:
- name
- email
This is semantically stricter โ the schema explicitly declares it must be an object, rather than leaving the type to be inferred from the allOf members.
$ref deduplication in allOfWhen a class extends a parent that has its own schema, the Inheritance augmenter adds a $ref to the parent in allOf. If you also declare that same $ref explicitly, spec mode deduplicates it โ only one entry survives. Classic mode may emit the same $ref twice.
#[OA\Schema(
schema: 'create-user',
allOf: [
new OA\Schema(ref: '#/components/schemas/abstract-user'),
new OA\Schema(required: ['name', 'email']),
]
)]
class CreateUser extends AbstractUser {}
In spec mode, the $ref to abstract-user appears only once in the output even though both the explicit allOf and the inheritance augmenter would add it.
type arrays reduced to stringWhen a schema's type is an array with a single element (e.g. ['string']), spec mode compiles it as a plain string (type: 'string'). Classic mode may emit the array form.
#[OA\Property]
#[OA\Schema(type: ['string'])]
public string $name;
Classic output:
name:
type:
- string
Spec output:
name:
type: string
Both forms are valid in OpenAPI 3.1+, but the scalar form is more conventional for single types.
requestBody on Get, Head, Options, TraceIn spec mode, the typed operation subclasses OA\Operation\Get, OA\Operation\Head, OA\Operation\Options, and OA\Operation\Trace do not accept a requestBody parameter. This enforces the HTTP semantics where request bodies are not defined for these methods.
Classic mode accepts requestBody on all operations (with a comment noting it should be ignored by validators for methods that don't support it).
// This works in classic mode but is a compile error in spec mode:
#[OA\Operation\Get(path: '/pets', requestBody: new OA\RequestBody(...))]
// Use Post, Put, Patch, or Delete for request bodies:
#[OA\Operation\Post(path: '/pets', requestBody: new OA\RequestBody(...))]
$ref does not duplicate descriptionWhen a schema combines a $ref with nullable: true and a description, classic mode emits the description both on the $ref entry inside oneOf and as a sibling of oneOf. Spec mode only emits it on the $ref entry โ no redundant duplication.
#[OA\Schema(
ref: '#/components/schemas/repository',
description: 'The repository',
nullable: true,
)]
Classic output (3.1+):
oneOf:
- { $ref: '#/components/schemas/repository', description: 'The repository' }
- { type: 'null' }
description: 'The repository'
Spec output (3.1+):
oneOf:
- { $ref: '#/components/schemas/repository', description: 'The repository' }
- { type: 'null' }
When traits without their own #[OA\Schema] are merged inline, the property order in the output may differ between modes. Spec mode orders properties by trait use declaration order, which may place trait properties differently than classic mode.
Spec mode consistently infers nullability from PHP type declarations (e.g. ?\DateTime). For OpenAPI 3.0 this adds nullable: true; for 3.1+ it emits type: ['string', 'null']. Classic mode may not infer nullability in all cases where the PHP type is nullable.
#[OA\Property]
#[OA\Schema(format: 'date-time', type: 'string', readOnly: true)]
public ?\DateTime $deleted_at;
Classic output (3.0):
deleted_at:
type: string
format: date-time
readOnly: true
Spec output (3.0):
deleted_at:
type: string
format: date-time
nullable: true
readOnly: true
$ref values can use PHP class references (resolved by the Refs augmenter):
#[OA\Response(
response: 200,
content: [new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(ref: Pet::class),
)],
)]
The FQCN is resolved to the appropriate #/components/schemas/Pet JSON reference at augmentation time.
OA\Schema\Ref is a restricted Schema subclass that only accepts ref, title, and description. It can be used in two ways:
As a schema โ when you want a reference-only schema with optional metadata overrides (OpenAPI 3.1+):
#[OA\Property(schema: new OA\Schema\Ref(ref: Pet::class, description: 'The pet'))]
public Pet $pet;
On a ref parameter directly โ when the ref property only accepts strings but you want type-safety or IDE completion for the reference target:
#[OA\Response(ref: new OA\Schema\Ref(ref: '#/components/responses/NotFound'), response: 404)]
When used on ref directly, the Refs augmenter unwraps it to the plain string value before further resolution.