docs/spec-attributes.md
Living document — tracks design decisions and progress for the spec attributes pipeline. Will become a formal ADR once the feature is complete.
The classic swagger-php pipeline was designed around annotation objects that are deeply mutable, carry their own serialization logic (jsonSerialize), and rely on a complex processor chain for assembly. Over time this has created several pain points:
$_nested arrays and reflection-heavy logicThe spec attributes pipeline introduces a clean separation of concerns with explicit, typed PHP 8.1+ attributes.
Source files → Assembler → Specification → Augmenters → Compiler → OpenAPI document
Slot-map driven nesting. Each attribute declares its relationships via two methods:
merge() returns [ParentClass => 'slot'] — how this attribute composes into siblings on the same reflectorcontains() returns [ChildClass => 'slot'] — which child attributes from inner reflectors this attribute absorbsSlots use [] suffix for collection append ('parameters[]'), bare name for scalar assignment ('requestBody'). This eliminates reflection-based nesting resolution entirely.
Two-pass assembly. The Assembler runs two distinct passes:
merge() mapscontains() mapsAfter both passes, only root attributes remain and are added to the Specification.
Root attributes. A "root" attribute is one that can exist independently in the Specification — it has its own bucket and doesn't require a parent container. After the two-pass assembly (merge + hierarchy resolution), every remaining attribute must satisfy isRoot() === true or the assembler throws an error.
Root DTOs have a corresponding bucket in Specification and are collected directly by the assembler:
Schema, Operation, PathItem, OpenApi, Info, Tag, Server, ExternalDocumentation, SecurityScheme, ComponentsResponse (when response key is set), RequestBody (when request key is set)Parameter, Header, Link, Example, MediaType, Property, etc.Non-root DTOs must be nested inside a parent (via merge()/contains() maps) or wrapped in a Components container. This is what makes Components necessary — it provides a root-level home for DTOs like Parameter and Header that cannot stand alone at class level.
isRoot() is validation, not routing. The merge() and contains() maps drive all nesting decisions — isRoot() does not control where an attribute ends up. Its role is purely a post-resolution assertion that catches mis-placed attributes early (e.g. a Parameter on a class without a sibling PathItem or Operation).
Custom DTOs should declare merge() targets to specify where they nest, and isRoot() to indicate whether they can land in a Specification bucket independently.
Immutable-style attributes. Spec attributes use constructor-promoted public properties. They carry no serialization logic — that responsibility belongs exclusively to compilers.
Version-aware compilers. Each OpenAPI version (3.0, 3.1, 3.2) has its own compiler that handles version-specific differences (nullable handling, exclusive min/max, webhooks, etc.) rather than branching inside a single serializer.
Typed subclasses. Common patterns get dedicated attribute classes to reduce boilerplate. The base classes (e.g. OA\Operation, OA\Parameter, OA\Flow) can always be used directly but require specifying fields that subclasses pre-fill — for example #[OA\Operation(path: '/pets', method: 'get')] vs #[OA\Operation\Get(path: '/pets')].
OA\AbstractAttribute
├── OA\Components
├── OA\OpenApi
├── OA\Info
│ ├── OA\Contact
│ └── OA\License
├── OA\Server
│ └── OA\ServerVariable
├── OA\Tag
├── OA\ExternalDocumentation
├── OA\Operation
│ ├── OA\Operation\Get
│ ├── OA\Operation\Post
│ ├── OA\Operation\Put
│ ├── OA\Operation\Delete
│ ├── OA\Operation\Patch
│ ├── OA\Operation\Head
│ ├── OA\Operation\Options
│ └── OA\Operation\Trace
├── OA\PathItem
├── OA\Schema
│ └── OA\Property
├── OA\Parameter
│ ├── OA\Parameter\Path
│ ├── OA\Parameter\Query
│ ├── OA\Parameter\Header
│ └── OA\Parameter\Cookie
├── OA\RequestBody
├── OA\Response
├── OA\Header
├── OA\MediaType
│ └── OA\Encoding
├── OA\Link
├── OA\Example
├── OA\Discriminator
├── OA\Xml
├── OA\Flow
│ ├── OA\Flow\Implicit
│ ├── OA\Flow\Password
│ ├── OA\Flow\ClientCredentials
│ └── OA\Flow\AuthorizationCode
└── OA\Security
├── OA\Security\Requirement
└── OA\Security\Scheme
├── OA\Security\Scheme\Http
├── OA\Security\Scheme\ApiKey
├── OA\Security\Scheme\OAuth2
├── OA\Security\Scheme\OpenIdConnect
└── OA\Security\Scheme\MutualTls
Every root DTO carries its originating reflector (ReflectionClass, ReflectionMethod, etc.). This is the fundamental mechanism for resolving cross-bucket relationships at augmentation time — the assembler is intentionally "dumb" (just collects into buckets), and augmenters use reflectors to reconnect what belongs together.
Key applications:
ReflectionMethod::getDeclaringClass() to find which PathItem governs an operation.ReflectionClass::getParentClass() to compose the full path prefix chain.This design keeps the assembler simple and makes cross-cutting relationships resolvable without coupling DTOs to each other.
The Builder class supports three modes via setMode('classic'|'spec'|'hybrid'):
Generator pipelineGenerator for scanning only (MergeJsonContent/MergeXmlContent), then iterates the Analysis annotations directly via HybridBridge into a Specification and runs it through the full spec augmenter chain and compilersAttributeFactory extracted from Assembler for standalone attribute instantiationHybridBridge iterates Analysis annotations directly into Specification DTOs (no tree assembly needed)PipeInterface with @template generics, Pipeline grouping (resolve → reduce → augment), Pipeline::get() for typed configurationSpecificationWalker — instance-based tree traversal with unified schema descentType\TypeResolver core producing SchemaType value objects — used by both the spec-attributes Type augmenter and the classic TypeInfoTypeResolver, confirming identical type resolution behavior| Augmenter | Group | Description | Status |
|---|---|---|---|
Type | resolve | Infers schema type, format, nullable, items, refs from PHP types and docblocks | Done |
Ref | resolve | Resolves FQCN $ref values to JSON Reference paths, including discriminator mappings | Done |
Docblock | augment | Summary, description, deprecated from PHPDoc | Done |
OperationId | augment | Generates operationId from reflector context, with hash option | Done |
Tag | augment | Auto-generates global tags from operation usage | Done |
ExpandHierarchy | augment | Trait/interface allOf composition, non-schema interface member merging | Done |
Enums | augment | Resolves PHP enum backing types into schema enum/type values | Done |
InferNames | augment | Auto-names component keys from reflector class/context | Done |
MediaType | augment | Re-keys encoding by property name | Done |
CleanUnused | reduce | Removes unreferenced components (iterative, handles nested deps) | Done |
PathFilter | reduce | Filters operations by tag/path regex patterns | Done |
PathItemResolve | resolve | Prefix composition, clone-down of tags/security/responses, path inference | Done |
All 10 example specs now have spec-attribute versions. Most produce identical output across classic, spec, and hybrid modes (shared yaml fixtures). Hybrid mode is tested for all examples and passes except using-refs (ref-path difference).
| Example | Shared fixture | Spec-specific fixture | Notes |
|---|---|---|---|
| api | ✓ | — | Original spec example |
| misc | ✓ | — | Callbacks, security, enums |
| nesting | ✓ | — | Multi-level class inheritance |
| petstore | ✓ | — | Classic CRUD example |
| polymorphism | ✓ | — | oneOf/allOf/discriminator |
| using-interfaces | ✓ | — | Interface-based allOf composition |
| using-links | ✓ | — | Response links |
| using-refs | — | ✓ | PathItem works; hybrid excluded due to ref-path difference |
| using-traits | — | ✓ | Bug fix: explicit type inference |
| webhooks | ✓ | — | 3.1+ webhooks |
using-refs — PathItem with path-level parameters works correctly. The spec example uses #[OA\PathItem(parameters: [...])] to emit parameters at path level. The example uses a spec-specific fixture due to a ref-path difference: classic emits Product/allOf/1/properties/id while spec/hybrid emits Product/properties/id (spec puts properties directly rather than in an allOf wrapper).
using-traits — Not a gap; intentional improvement. The spec/hybrid pipeline infers explicit types from PHP declarations more thoroughly than classic.
Intentional improvements or corrections in the spec/hybrid pipeline that produce different output from classic:
| Difference | Classic | Spec/Hybrid | Rationale |
|---|---|---|---|
Empty tags | Always emits tags: [] | Omits when empty | Per OpenAPI spec, optional fields should be omitted when empty |
Empty flow scopes | Emits scopes: {} | Was omitting empty scopes | Bug fix in compiler — scopes is required per OpenAPI spec |
| Type inference on traits | Types often omitted | Explicit types from PHP declarations | Bug fix — classic failed to resolve types on trait properties |
| Docblock on parameters | Only from @param on ReflectionParameter | Also matches by parameter name | More thorough — resolves descriptions for non-reflection parameters |
| Promoted property descriptions | Resolved via constructor parameter context | Resolved via ReflectionProperty fallback | Different mechanism, same result |
| Schema property refs | Product/allOf/1/properties/id | Product/properties/id | Spec pipeline places properties directly on the schema rather than wrapping in allOf — both specs are internally consistent, but $ref paths into schema internals differ |
How each classic processor maps to the new pipeline:
| Classic Processor | Spec-Attributes Equivalent | Stage | Status |
|---|---|---|---|
| ExpandClasses | ExpandHierarchy + Assembler (contains() maps) | augment + assembly | Done |
| ExpandTraits | ExpandHierarchy + Assembler (contains() maps) | augment + assembly | Done |
| ExpandInterfaces | ExpandHierarchy + Assembler (contains() maps) | augment + assembly | Done |
| ExpandEnums | Enums | augment | Done |
| MergeIntoOpenApi | Assembler (builds Specification) | assembly | Done |
| MergeIntoComponents | Compiler (groups into components) | compile | Done |
| MergeJsonContent | N/A — attribute eliminated | — | N/A |
| MergeXmlContent | N/A — attribute eliminated | — | N/A |
| BuildPaths | Compiler (groups by path) | compile | Done |
| AugmentSchemas | Split (see below) | mixed | Done |
| AugmentProperties | Type (type/format/nullable + property name) | resolve | Done |
| AugmentParameters | Type (type/name/required inference) | resolve | Done |
| AugmentItems | Type (array items via SchemaType.items) | resolve | Done |
| AugmentRequestBody | Type (required inference from nullable) | resolve | Done |
| AugmentRefs | Ref (FQCN → JSON reference) | resolve | Done |
| AugmentDiscriminators | Ref (discriminator mapping resolution) | resolve | Done |
| AugmentTags | Tag (auto-generate tags from operations) | augment | Done |
| AugmentMediaType | MediaType (re-key encoding by property name) | augment | Done |
| DocBlockDescriptions | Docblock (summary/description/deprecated) | augment | Done |
| OperationId | OperationId | augment | Done |
| CleanUnmerged | Assembler (orphan validation in resolveHierarchy) | assembly | Done |
| CleanUnusedComponents | CleanUnused (iterative, handles nested deps) | reduce | Done |
| PathFilter | PathFilter | reduce | Done |
AugmentSchemas split: This processor's responsibilities are distributed across four concerns:
InferNames (done)type: object inference when properties present → Type (done)merge()/contains() (done)PathItem is both an OpenAPI spec concept (path-level parameters, summary, description, servers) and a controller-grouping mechanism (prefix composition, shared tags/security). The DTO unifies both roles.
OA\PathItemClass-level only. No path property — path is always inferred from the operations the PathItem governs.
| Property | OpenAPI output | Controller role |
|---|---|---|
prefix | — (resolved away) | Composable path prefix, inherited via class hierarchy |
parameters[] | Path-level parameters | Shared parameters for all operations |
summary | Path-level summary | — |
description | Path-level description | — |
servers[] | Path-level servers | — |
tags[] | — (cloned to operations) | Shared tags for all operations |
security[] | — (cloned to operations) | Shared security for all operations |
responses[] | — (cloned to operations) | Shared responses for all operations |
PathItemResolve (resolve group, early)ReflectionClass::getParentClass() chain, collect ancestor PathItems, compose full prefixpathItem->path (derived) and keep in bucket. Remove prefix-only PathItems with no path-level output.The compiler emits PathItem properties (parameters, summary, description, servers) at path level in the paths output, alongside the operation entries grouped by method.
#[OA\PathItem(prefix: '/api/v1')]
class BaseController {}
#[OA\PathItem(prefix: '/users', tags: ['Users'], parameters: [
new OA\Parameter\Path(name: 'tenant', schema: new OA\Schema(type: 'string')),
])]
class UserController extends BaseController {
#[OA\Operation\Get(path: '/list')]
public function list() {}
#[OA\Operation\Get(path: '/{id}')]
public function get($id) {}
}
Output paths:
/api/v1/users/list — get operation, tags: ['Users'], path-level parameter: tenant/api/v1/users/{id} — get operation, tags: ['Users'], path-level parameter: tenant#[OA\Components] is a class-level attribute that acts as a container for reusable component definitions that cannot stand on their own as root attributes.
The Components attribute itself is never emitted in the output. During assembly, its children are promoted directly into the Specification's top-level buckets, just as if they had been collected from a Schema or PathItem class.
The primary use case is for DTOs that are not roots and therefore cannot be declared at class level on their own:
Other types (Schema, PathItem, SecurityScheme, named Response, named RequestBody) are already roots and can be declared directly on a class without needing a Components wrapper. They are accepted by Components for completeness but don't require it.
#[OA\Components]
class SharedComponents {
#[OA\Parameter(parameter: 'page', name: 'page', in: 'query', schema: new OA\Schema(type: 'integer'))]
public int $page;
#[OA\Parameter(parameter: 'per_page', name: 'per_page', in: 'query', schema: new OA\Schema(type: 'integer'))]
public int $perPage;
#[OA\Header(header: 'X-Rate-Limit', description: 'Requests remaining', schema: new OA\Schema(type: 'integer'))]
public string $rateLimit;
#[OA\Example(example: 'dog', summary: 'A dog example', value: ['name' => 'Fido'])]
public string $dogExample;
}
These components can then be referenced via $ref from operations, PathItems, or other DTOs.
HybridBridge (currently exercised only indirectly via examples)setMode('hybrid') on their Builder — most classic tests should pass through hybrid unchanged, exposing gapsThree extension points to implement:
AttributeEnricher — hook into assembly to translate non-OA attributes (e.g. Symfony #[Assert\*], framework route annotations) into spec DTOs. Runs per-element during the factory/assembler phase.CompilerExtension — lets custom Attachable DTOs produce spec output. Extensions declare which Attachable class(es) they handle and return key-value pairs merged into the parent's compiled output. Unhandled Attachables are silently omitted.Attachable DTO — an empty class extending AbstractAttribute. Gets its own Specification bucket by default (unless inlined via merge()). Ignored by the standard pipeline — custom augmenter pipes can process them however they want. Mirrors the existing Attachable pattern from the classic pipeline.These extension systems should also address downstream integration needs (e.g. Nelmio translating Symfony metadata, framework route annotations) without requiring those projects to couple to pipeline internals.
Re-evaluate support for convenience attributes that reduce boilerplate in common patterns:
Items — shorthand for array item schema declarationJsonContent / XmlContent — shorthand for wrapping a schema in a media type with the appropriate content typeThese could be implemented as assembler-level transforms (expand the shortcut into canonical DTOs during assembly) or as extension examples using AttributeEnricher + CompilerExtension. The latter approach would serve as both useful functionality and documentation of how the extension systems work in practice.
Need to document the general pattern for shortcut attributes: how they participate in nesting (via merge()), when they expand (assembly vs augmentation), and how custom shortcuts can be added by users.
EnumDescription — generate human-readable descriptions from PHP enum cases (ported from openapi-extras). Ships in the default pipeline but disabled by default; enable via $builder->getAugmenters()->get(EnumDescription::class)->setEnabled(true).OpenApiException for all validation issues. Some issues (orphan attributes, unknown Attachable types, duplicate attributes where last-wins is acceptable) could be warnings via PSR logger instead. Need to decide the boundary: structural issues that prevent valid output remain exceptions; issues that don't block compilation become warnings surfaced via Result::warnings().setHash(bool), setEnabled(bool)). Tools that load config from files (YAML/JSON) only have untyped arrays. Consider a generic configure(array $options) method on PipeInterface so external tooling can pass arbitrary key-value config without needing to know the typed API. Typed setters remain the primary API for programmatic use.setMode('spec')). Classic remains default. Both modes available side-by-side.setMode('classic'). Remove legacy namespaces (Annotations\*, Attributes\*), Context, Analysis, doctrine support. Introduce ProcessorInterface::process(Specification).Re-evaluate support for convenience attributes that reduce boilerplate in common patterns:
Items — shorthand for array item schema declaration; this probably should be extending OA\Items and get a dedicated PipeInterface augmenter.JsonContent / XmlContent — shorthand for wrapping a schema in a media type with the appropriate content type; a new AttributeTranslatorInterface should be implemented to handle the translation of these attributes.OA\Property if OA\Schema present and the default property name is used (empty #[OA\Property]))OA\Schema\Ref attribute (with title/description 3.1.0+), $ref required attribute - extends OA\Schema