Spec pipeline internals โ
The parts of the spec pipeline that are easiest to get wrong when reading the source. For how the pipeline fits together, see Architecture.
The DTOs are mutable โ
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 is that they carry no serialization logic. Serialization is the compiler's job.
Slot maps: the slot belongs to the parent โ
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 level
Both 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.
The target's own type has to admit the class declaring the slot โ Schema once named Schema::$properties, a list<Property>, which crashed the inheritance augmenter as soon as anything reached it. SlotMapConsistencyTest checks every slot against its target.
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.
How resolution runs โ
Resolution is driven purely by these declarations โ it reads nothing from PHP's own structural semantics:
- Sibling merge โ attributes on the same reflector compose via
merge() - Hierarchical absorb โ what remains flows upward a level at a time via
contained(), first match wins
Sibling merge chains resolve inner-to-outer, not in declaration order: a MediaType stacked with a Response and an Operation finds its Response before the Response is folded into the Operation, whichever way the three are declared. Only attributes whose types name each other as merge targets โ possible with custom attachables, not among the native attributes โ cannot be ordered that way and resolve in declaration order instead.
If 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.
What survives resolution โ
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.
- Always root:
Schema,Operation,PathItem,OpenApi,Info,Tag,Server,ExternalDocumentation,Security\Scheme,Components,Attachable - Conditionally root, when their own key is set and
refis not:Response(response),Parameter(parameter),Link(link);RequestBodyneedsrequestset but has norefcheck - Never root:
Header,Example,MediaType,Propertyโ these must nest inside a parent or sit in aComponentscontainer
Each attribute decides for itself, in isRoot().
Root does not mean un-nested. isRoot() says what an attribute may be when nothing consumes it; merge() and contained() run first, and often do. Schema is always root, and is routinely merged into a sibling โ a Property or a MediaType, say โ instead of reaching the Specification at all.
The user-facing version of this distinction is in Using Spec Attributes; this list is the full one.
Directory layout is not the class hierarchy โ
src/Spec/ nests directories for readability, not inheritance. Notably:
Property extends AbstractAttributeโ notSchema. Being siblings is what lets#[OA\Property]and#[OA\Schema]stack on the same target.Encoding extends AbstractAttributeโ notMediaType, despiteProperty\Encoded.Contact,License,ServerVariableextendAbstractAttribute, notInfo/Server.OA\Securityis a namespace, not a class. The classes areSecurity\RequirementandSecurity\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.
Reflectors are the glue โ
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:
- PathItem to Operation โ a PathItem sits on a class, operations on its methods; the
PathItemsaugmenter usesReflectionMethod::getDeclaringClass()to pair them - Prefix composition โ
ReflectionClass::getParentClass()walks ancestors so parent PathItems can contribute path prefixes - OperationId generation โ class and method names come from the reflector
- Type inference โ
Typesreads PHP type declarations off property and parameter reflectors - Naming โ a component schema takes its name from the class it sits on; a property takes its name from the property, parameter or constant it sits on
This is what keeps the Assembler concerned only with nesting.
A method reflector supplies no name โ getUnnamed() is not the property unnamed, and the pipeline does not guess at accessor prefixes. So a bare Schema becomes a Property only where the reflector declares a value the schema owns: a property, or a constructor parameter. On a method, or on a parameter of anything else, the attribute carries its own schema or property or the compiler reports it missing.
Normalise on input, store the simple form โ
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.
null means unset, except where null is a value โ
A nullable property defaults to null, and filter() in the compilers drops it. That breaks down for mixed properties: null is a legal example, default or const, so it cannot also mean "not set". Those default to Undefined::UNDEFINED instead.
// OA\Schema โ null is a valid default, so the sentinel has to be something else
public mixed $default = Undefined::UNDEFINED,filter() drops null, Undefined::UNDEFINED and [] alike, so a compiler that only needs "omit when unset" gets that behaviour without doing anything. Emitting an explicit null or [] โ both legal values โ takes a branch outside the filter() call, which is what compileSchema() does for default, const and example, and compileExample()/compileLink() for value and requestBody.
Classic uses the same sentinel โ Generator::UNDEFINED is an alias of Undefined::UNDEFINED โ so HybridBridge passes these values straight through rather than translating them.
An inferred field needs the sentinel too โ
There is a second reason to reach for Undefined::UNDEFINED, and it decides which nullable properties get it. Undefined::isDefault() is true only for the sentinel, never for null, so an augmenter that fills a field in guards on it:
// Augmenter\Docblocks
if (!Undefined::isDefault($schema->description)) {
return;
}Writing description: null therefore means no description, and the docblock is left alone; leaving it out means "infer one". The attribute always wins. Classic behaves the same way. A field nothing infers keeps a plain null default โ there is nothing to suppress.
That is what separates the two sets. summary and description on the operations, parameters and schemas default to the sentinel because Docblocks and EnumDescriptions fill them; the rest of the nullable properties do not.
Adding inference for a field means changing its default. Guarding a field that still defaults to null makes the guard true for every attribute, and the inference silently never runs. DocblocksTest pins both halves โ that an absent value is inferred, and that an explicit null suppresses it.
Why resolution is its own step โ
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 values โ raw FQCN strings in
$ref, not yet rewritten to#/components/..., so no dependency onNamesorRefs - Reflector types โ non-builtin types on properties and constructor parameters, read straight off
\ReflectionProperty::getType(), so no dependency onTypes
A 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.
Augmenter ordering lives in one place โ
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.