docs/dev/pipeline.md
The parts of the spec pipeline that are easiest to get wrong when reading the source. For how the pipeline fits together, see Architecture.
Spec attributes are plain data containers, but they are not immutable, and neither is
Specification. Augmenters assign to attribute properties throughout —
Refs::mergeAllOf() nulls $schema->properties, Types fills schema fields in place —
and Specification exposes public arrays that add() appends to.
What is true, and what distinguishes them from classic annotations, is that they carry no serialization logic. Serialization is the compiler's job.
Nesting is declared by the child, via two methods on AttributeInterface:
merge() — how this attribute composes into a sibling on the same reflectorcontained() — which outer-level attribute types can absorb it from an inner levelBoth return [TargetClass => 'slot'], and in both cases the slot names a property on the
target, not on the attribute declaring it:
// OA\Property
public function contained(): array
{
return [Schema::class => 'properties[]']; // `properties` lives on Schema
}
A [] suffix appends to a collection; a bare name assigns a scalar. It is easy to read
backwards.
Because the child declares the relationship, downstream code can extend the system: a custom attachable names its own nesting targets without touching any native attribute.
Resolution is driven purely by these declarations — it reads nothing from PHP's own structural semantics:
merge()contained(),
first match winsIf a level has containers, an unmatched non-root attribute is an error. If a level has no containers at all, unmatched attributes pass through to the level above.
Only root attributes should remain, and those are what enters the Specification. A root attribute is one that can stand alone — it owns a bucket and needs no parent.
Schema, Operation, PathItem, OpenApi, Info, Tag, Server,
ExternalDocumentation, Security\Scheme, Components, Attachableref is not: Response
(response), Parameter (parameter), Link (link); RequestBody needs request
set but has no ref checkHeader, Example, MediaType, Property — these must nest inside a
parent or sit in a Components containerEach attribute decides for itself, in isRoot().
The user-facing version of this distinction is in Using Spec Attributes; this list is the full one.
src/Spec/ nests directories for readability, not inheritance. Notably:
Property extends AbstractAttribute — not Schema. This is the deliberate change
from classic (where Annotations\Property extends Schema) and it is what makes stacking
#[OA\Property] and #[OA\Schema] on the same target work.Encoding extends AbstractAttribute — not MediaType, despite Property\Encoded.Contact, License, ServerVariable extend AbstractAttribute, not Info/Server.OA\Security is a namespace, not a class. The classes are Security\Requirement and
Security\Scheme.Genuinely nested: Schema\{AdditionalProperties,Items,Ref}, Property\Encoded,
Operation\*, Parameter\*, MediaType\{Json,Xml}, Flow\*, Security\Scheme\*.
The Spec Attributes reference lists every attribute with its parameters and what it can nest into.
Every root DTO keeps the reflector it came from. This is how relationships that span buckets get resolved after assembly, without the DTOs having to reference each other:
PathItems augmenter uses ReflectionMethod::getDeclaringClass() to pair themReflectionClass::getParentClass() walks ancestors so parent
PathItems can contribute path prefixesTypes reads PHP type declarations off property and parameter
reflectorsThis is what keeps the Assembler concerned only with nesting.
Where a property accepts both a rich input type and a plain serialized one, take both but convert immediately in the constructor. Properties always hold the simple form; enums, objects and convenience types are input sugar only.
// OA\Flow — FlowType accepted on input, stored as string
public function __construct(
string|FlowType|null $flow = null,
// ...
) {
parent::__construct(x: $x, attachables: $attachables);
$this->flow = $flow instanceof \BackedEnum ? $flow->value : $flow;
}
Augmenters, compilers and serialization then never need to branch on type.
Resolving unknown classes inside the augmenter pipeline would create an ordering problem: an
augmenter that adds schemas runs after Names and Types, so it would have to re-invoke
them on whatever it added. Running resolution between assembly and augmentation means every
schema exists before the augmenters start their single pass.
Discovery draws on two sources, both readable before any augmenter has run:
$ref, not yet rewritten to #/components/..., so
no dependency on Names or Refs\ReflectionProperty::getType(), so no dependency on TypesA ComponentIndex deduplicates against what the specification already holds.
Resolution then runs as a convergence loop: discover, hand each FQCN to the chain until one claims it, re-discover. That is what handles transitive references — resolving class A can introduce a schema referencing class B, which the next pass picks up.
Registration order is Builder::getDefaultAugmenters(). The phase each augmenter runs in
comes from its own group() — Resolve → Reduce → Augment. Within a phase, execution
follows registration order.
Ordering that matters: Inheritance must run before PathItems, because inherited
operations have to exist before path prefixes are resolved.