Skip to content

๐Ÿงช Using Spec Attributes โ€‹

Beta

Spec attributes are mostly feature-complete but still 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 PHP code for OpenAPI generation. They live in the OpenApi\Spec namespace and are typed data containers. Unlike classic annotations they carry no serialization logic, and the augmenter pipeline fills in derived values before compilation.

Namespace โ€‹

php
use OpenApi\Spec as OA;

This replaces the classic use OpenApi\Attributes as OA;. The OA alias keeps the code familiar.

Basic example โ€‹

A minimal working API with spec attributes:

php
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 {}
php
use OpenApi\Spec as OA;

#[OA\Schema]
class Pet
{
    #[OA\Property]
    public int $id;

    #[OA\Property]
    public string $name;

    #[OA\Property]
    public ?string $tag;
}
php
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:

php
use OpenApi\Builder;
use OpenApi\Builder\Mode;

$result = (new Builder())
    ->setMode(Mode::SPEC)
    ->addSource('src/')
    ->build();

echo $result->toYaml();

Or via CLI:

shell
./vendor/bin/openapi src/ --mode spec

Schemas โ€‹

Place #[OA\Schema] on a class to define a schema component. Properties are declared with #[OA\Property] on class properties.

php
use OpenApi\Spec as OA;

#[OA\Schema(title: 'User', required: ['email'])]
class User
{
    #[OA\Property]
    #[OA\Schema(format: OA\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).

type and format take a string, or a case of the OA\Type and OA\Format enums (see Shortcuts). Where the attribute, the docblock and the type declaration disagree, Augmentation lists which one wins.

Stacking Schema and Property โ€‹

In spec mode OA\Property no longer extends OA\Schema. Both derive directly from AbstractAttribute, so #[OA\Property] and #[OA\Schema] can be stacked on the same target:

php
#[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 โ€‹

Operations map to HTTP methods on API endpoints. Spec mode provides typed subclasses for each HTTP method:

php
#[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')]
#[OA\Operation\Trace(path: '/pets')]

You can also use the base class with an explicit method:

php
#[OA\Operation(path: '/pets', method: 'get')]

Responses and parameters โ€‹

Responses and parameters can be nested inside the operation or placed as separate attributes on the same method:

php
// 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) {}

The flat form extends to deeper nesting: a MediaType and Schema stacked alongside a Response merge into it, in any declaration order.

php
#[OA\Operation\Get(path: '/pets/{id}')]
#[OA\Response(response: 200, description: 'OK')]
#[OA\MediaType(mediaType: 'application/json')]
#[OA\Schema(type: 'string')]
public function show(int $id) {}

The merge is by attribute type, so the flat form only works while it is unambiguous. Two Response siblings each expecting their own MediaType, or a MediaType next to both a Response and a RequestBody, fail with an Ambiguous merge error. Nest those inline instead, or see One attribute for several targets.

Parameters on method arguments โ€‹

Parameters can be placed directly on method arguments:

php
#[OA\Operation\Get(path: '/pets/{petId}')]
public function show(
    #[OA\Parameter(name: 'petId', in: 'path', required: true)]
    int $petId
) {}

Typed parameter subclasses reduce boilerplate:

php
#[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.

One attribute for several targets โ€‹

An attribute that more than one attribute could hold is an error: a Response next to two operations, or a parameter on an argument shared by two operations. fan: OA\Fan::All puts it in each of them instead:

php
#[OA\Operation\Put(path: '/pets/{id}')]
#[OA\Operation\Delete(path: '/pets/{id}')]
#[OA\Response(response: 404, description: 'Not found', fan: OA\Fan::All)]
public function change(
    #[OA\Parameter\Path(name: 'id', fan: OA\Fan::All)]
    int $id
) {}

Both operations get the 404 response and the id parameter. The same works one level down, for a Header next to several responses. Each target gets its own copy.

The targets have to be of one type. A MediaType next to both a Response and a RequestBody stays an error with fan: OA\Fan::All, since which one it belongs to is still a choice.

PathItem โ€‹

#[OA\PathItem] on a class groups operations with shared configuration. It enables prefix composition via class inheritance and pushes shared metadata down to operations.

php
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.

What PathItem provides โ€‹

FeatureDescription
prefixComposable path prefix, inherited via class hierarchy
tagsCloned to all operations that don't declare their own
securityCloned to all operations that don't declare their own
responsesCloned to all operations (e.g. shared error responses)
parametersEmitted as path-level parameters in the output
summary / descriptionEmitted at path level
serversEmitted at path level

Tags โ€‹

#[OA\Tag] declares a tag at the document level. On a method it also tags that method's operations, and on a class every operation of the class:

php
use OpenApi\Spec as OA;

#[OA\Tag(name: 'users', description: 'Everything about users')]
class UserController
{
    #[OA\Tag(name: 'admin')]
    #[OA\Operation\Delete(path: '/users/{id}')]
    public function delete(int $id) {}

    #[OA\Operation\Get(path: '/users')]
    public function list() {}
}

DELETE /users/{id} is tagged admin and users, and GET /users is tagged users. An operation's own tags come first, then its method's, then its class's. On a class with no operations, such as one holding only Info, the tag is a declaration and nothing more.

The same tag can be declared in several places. They fold into one: a tag that is only a name adds nothing, and a field two of them set differently is reported, keeping the later value.

Shortcuts โ€‹

MediaType โ€‹

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.

php
// 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))])]

Items โ€‹

#[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.

Since OA\Schema\Items extends OA\Schema, the implicit OA\Property shortcut applies, and the explicit #[OA\Property] attribute can be omitted:

php
// Verbose
#[OA\Property]
#[OA\Schema(type: 'array', items: new OA\Schema(ref: Tag::class))]
public array $tags;

// With Items shortcut
#[OA\Property]
#[OA\Schema\Items(ref: Tag::class)]
public array $tags;

// With Items shortcut + implicit Property
#[OA\Schema\Items(ref: Tag::class)]
public array $tags;

Both the MediaType and Items shortcuts are resolved by the Shortcuts augmenter.

Encoded โ€‹

OA\Property\Encoded bundles a property definition with its encoding. Instead of a separate OA\Encoding on the OA\MediaType, the encoding is declared on the property itself. The MediaTypes augmenter promotes the encoding to the parent MediaType automatically.

If no encoding property name is set on the nested OA\Encoding, it defaults to the property name.

php
// Without shortcut โ€” encoding declared separately on the MediaType
#[OA\Response(response: 200, content: [
    new OA\MediaType\Json(
        properties: [
            new OA\Property(property: 'avatar', schema: new OA\Schema(type: 'object')),
        ],
        encoding: [
            new OA\Encoding(encoding: 'avatar', contentType: 'image/png, image/jpeg'),
        ],
    ),
])]

// With shortcut โ€” encoding bundled with the property
#[OA\Response(response: 200, content: [
    new OA\MediaType\Json(
        properties: [
            new OA\Property\Encoded(
                property: 'avatar',
                schema: new OA\Schema(type: 'object'),
                encoding: new OA\Encoding(contentType: 'image/png, image/jpeg'),
            ),
        ],
    ),
])]

The Encoded shortcut is resolved by the MediaTypes augmenter.

AdditionalProperties โ€‹

OA\Schema\AdditionalProperties is a typed alias for OA\Schema. It is functionally identical, and reads better when declaring additionalProperties constraints:

php
#[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 {}

Components โ€‹

Any reusable attribute becomes a component by giving it a component: key. That is the name it is filed under in the document's components section, and the name a $ref points at. Schemas, responses, parameters, request bodies, headers, links, examples, security schemes and path items all take it, and a keyed attribute is a root: it can be declared on a class by itself, with no wrapper.

php
use OpenApi\Spec as OA;

#[OA\Parameter(component: 'page', name: 'page', in: 'query')]
#[OA\Schema(type: 'integer', default: 1)]
class PageParameter {}

#[OA\Header(component: 'RateLimit', description: 'Requests remaining')]
#[OA\Schema(type: 'integer')]
class RateLimitHeader {}

#[OA\Response(component: 'NotFound', description: 'No such thing')]
class NotFoundResponse {}

These are then referenced from operations via $ref:

php
#[OA\Operation\Get(path: '/users', parameters: [
    new OA\Parameter(ref: '#/components/parameters/page'),
], responses: [
    new OA\Response(response: 404, ref: '#/components/responses/NotFound'),
])]
public function list() {}

Note the two keys on that response: response: 404 is where it nests in the operation, and NotFound is the component it points at. The same split holds for a header (header: is the HTTP header name, component: the reusable definition), a link and an example. A schema declared on a class needs no key at all. It is named after the class.

#[OA\Components] remains as a class-level container for declaring several components on one class, and for the historic spellings below. On a class it also makes a holder. Each method or property declares a component of its own, and one without a key is named after the member, the way a class names its own. That saves an empty class per schema, and the member's docblock becomes the description:

php
#[OA\Components]
class Models
{
    /** A thing someone owns. */
    #[OA\Schema(type: OA\Type::Object, properties: ['id' => OA\Type::Integer])]
    public function thing() {}

    #[OA\Response(description: 'No such thing')]
    public function notFound() {}
}

That gives #/components/schemas/thing and #/components/responses/notFound. A parameter is still keyed by its name, as anywhere else.

A component declared on a member needs the holder, keyed or not. Without it, a schema on a property is that property's own, and a keyed component on a method or property is left out of the document with a warning.

Historic spellings, deprecated

Before 6.12 each type spelled its key after itself: schema: 'Pet', parameter: 'page', request: 'Body', securityScheme: 'api', and, only when declared as a component, response: 'NotFound', header: 'RateLimit', link: 'Self', example: 'Minimal'. They still work, produce the same document, and trigger a deprecation. They are removed in 8.0. Only the component use is deprecated: response: 404 on a nested response, or header: 'X-Rate-Limit' on a header inside a response, is the nesting key and stays.

Classic is not affected and reports nothing. OpenApi\Attributes keeps schema:, parameter:, request: and securityScheme: as its component keys, and hybrid mode translates them to component: through the bridge. What this does change is the price of moving a classic codebase onto OpenApi\Spec: it is no longer a change of use line and nothing else, because every component key is renamed with it. See Migration path.

Inheritance โ€‹

Spec attributes support schema composition via PHP class hierarchy. The rules mirror PHP inheritance:

  • Parent has a schema โ†’ add $ref to allOf
  • Parent has no schema โ†’ merge its properties inline
  • Same rule applies to traits and interfaces
php
use 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:

yaml
User:
  allOf:
    - $ref: '#/components/schemas/BaseModel'
    - type: object
      properties:
        email:
          type: string
        name:
          type: string

Traits โ€‹

Traits with #[OA\Schema] are composed via $ref in allOf. Traits without a schema have their properties merged inline.

php
#[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:

yaml
Post:
  allOf:
    - $ref: '#/components/schemas/HasTimestamps'
    - type: object
      properties:
        title:
          type: string

Security โ€‹

Security schemes use typed subclasses:

php
use OpenApi\Spec as OA;

#[OA\Security\Scheme\Http(component: 'bearerAuth', scheme: 'bearer')]
#[OA\Security\Scheme\ApiKey(component: 'apiKey', name: 'X-API-Key', in: 'header')]
#[OA\Security\Scheme\OAuth2(component: '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:

php
#[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).

Each entry is a Security\Requirement. Classic also reads a bare scheme name, security: ['bearerAuth'], as that scheme with no scopes. Spec drops it with a warning, and the operation falls back to the requirements declared above it.

Differences from classic attributes โ€‹

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(...)]
Property extends SchemaProperty and Schema are siblings, stackable
Processors walk a nested annotation treeAugmenters enrich a flat set of typed buckets
Single serializer with version branchesDedicated compiler per OpenAPI version

Other differences โ€‹

Beyond the API changes listed above, spec mode produces slightly different output in some cases:

Single-element type arrays reduced to string โ€‹

When 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.

php
#[OA\Property]
#[OA\Schema(type: ['string'])]
public string $name;

Classic output:

yaml
name:
  type:
    - string

Spec output:

yaml
name:
  type: string

Both forms are valid in OpenAPI 3.1+, but the scalar form is more conventional for single types.

Nothing is read from a constructor โ€‹

Spec mode reads a constructor's parameters, as promoted properties, and nothing else on it. Classic reads attributes on the constructor as on any other method. Spec leaves them out of the document and logs a warning naming them. Declare them on the class, which takes any number of operations:

php
// classic reads this; spec ignores it, with a warning
#[OA\Post(path: '/organizations', operationId: 'createOrganization')]
public function __construct(OrganizationService $service)

// spec
#[OA\Operation\Post(path: '/organizations', operationId: 'createOrganization')]
#[OA\Operation\Get(path: '/organizations', operationId: 'getAllOrganizations')]
class OrganizationsController

No requestBody on Get, Head, Options, Trace โ€‹

In 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 do not support it). In spec mode the parameter simply does not exist, so passing it raises Error: Unknown named parameter $requestBody when the attribute is instantiated.

php
// Works in classic mode; a PHP error in spec mode, because
// Operation\Get::__construct() has no $requestBody parameter:
#[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(...))]

Nullable $ref does not duplicate description โ€‹

When 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.

php
#[OA\Schema(
    ref: '#/components/schemas/repository',
    description: 'The repository',
    nullable: true,
)]

Classic output (3.1+):

yaml
oneOf:
  - { $ref: '#/components/schemas/repository', description: 'The repository' }
  - { type: 'null' }
description: 'The repository'

Spec output (3.1+):

yaml
oneOf:
  - { $ref: '#/components/schemas/repository', description: 'The repository' }
  - { type: 'null' }

Nullable type inference from PHP types โ€‹

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 does not infer it when the attribute sets type.

php
#[OA\Property]
#[OA\Schema(format: 'date-time', type: 'string', readOnly: true)]
public ?\DateTime $deleted_at;

Classic output (3.0):

yaml
deleted_at:
  type: string
  format: date-time
  readOnly: true

Spec output (3.0):

yaml
deleted_at:
  type: string
  format: date-time
  nullable: true
  readOnly: true

References โ€‹

$ref values can use PHP class references (resolved by the Refs augmenter):

php
#[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.

Schema\Ref โ€‹

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: a reference-only schema with optional metadata overrides (OpenAPI 3.1+):

php
#[OA\Property(schema: new OA\Schema\Ref(ref: Pet::class, description: 'The pet'))]
public Pet $pet;

On a ref parameter directly: where the ref property only accepts a string, this adds type-safety and IDE completion for the reference target:

php
#[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.