Extension points
The spec pipeline is assembled from parts that can be replaced or added to. Between them they cover feeding it from somewhere other than a directory scan, accepting attributes it does not know, and deriving fields nobody wrote by hand.
They are described here roughly in the order the pipeline reaches them.
Translators
A translator runs during assembly, once per reflector. getAttributes() says which raw attributes to read off it, and translate() returns what the assembler should treat as declared.
Given a framework attribute that knows nothing about swagger-php:
<?php declare(strict_types=1);
namespace OpenApi\Snippets\Guide\ExtensionPoints;
#[\Attribute(\Attribute::TARGET_METHOD)]
final class Route
{
public function __construct(public string $path, public string $method = 'get')
{
}
}a translator turns it into a spec attribute:
<?php declare(strict_types=1);
namespace OpenApi\Snippets\Guide\ExtensionPoints;
use OpenApi\Contracts\AttributeTranslatorInterface;
use OpenApi\Spec as OA;
final class RouteTranslator implements AttributeTranslatorInterface
{
public function reset(): void
{
}
public function getAttributes(
\ReflectionClass|\ReflectionMethod|\ReflectionProperty|\ReflectionParameter|\ReflectionClassConstant $reflector,
): array {
return $reflector->getAttributes(Route::class);
}
public function translate(
array $attributes,
array $created,
\ReflectionClass|\ReflectionMethod|\ReflectionProperty|\ReflectionParameter|\ReflectionClassConstant $reflector,
): array {
foreach ($created as $route) {
if ($route instanceof Route) {
$attributes[] = new OA\Operation(
path: $route->path,
method: $route->method,
operationId: $reflector->getName(),
);
}
}
return $attributes;
}
}Turning a foreign attribute into a spec one is a use, not the use. The other is adding a native attribute nobody wrote, which is how swagger-php uses the mechanism itself. Both DefaultAttributeTranslator and OptionalPropertyAttributeTranslator ship by default. The extension points reference says what each does.
translate() sees $created, what this translator read on this pass, and $attributes, what earlier translators already resolved. It can add to either, replace them, or leave them alone. Ordering follows registration.
Translators are registered on the attribute factory, so withTranslators() nests inside withAttributeFactory():
$builder->withAttributeFactory(fn (AttributeFactory $factory) => $factory->withTranslators(
fn (TypedList $translators) => $translators->add(new RouteTranslator())
));Subclassing a spec attribute
Spec attributes are not final. A subclass can derive its own constructor arguments, usually by reflecting over the class it is given, which keeps the repetition out of the annotated code:
<?php declare(strict_types=1);
namespace OpenApi\Snippets\Guide\ExtensionPoints;
use OpenApi\Spec as OA;
#[\Attribute(\Attribute::TARGET_CLASS | \Attribute::IS_REPEATABLE)]
final class Dto extends OA\Schema
{
/**
* @param class-string $of
*/
public function __construct(string $of)
{
$class = new \ReflectionClass($of);
parent::__construct(
schema: $class->getShortName(),
required: array_map(
static fn (\ReflectionProperty $property): string => $property->getName(),
$class->getProperties(\ReflectionProperty::IS_PUBLIC),
),
);
}
}#[Dto(of: Pet::class)] then names the schema and marks every public property required, without either being written out. The subclass is an OA\Schema as far as the rest of the pipeline is concerned, so merging, containment and compilation treat it the same.
Contributing to the Specification
Builder::withSpecification() hands the assembled Specification to a callable before the resolver runs. Add attributes to it and they go through the rest of the pipeline as scanned ones do:
<?php declare(strict_types=1);
namespace OpenApi\Snippets\Guide\ExtensionPoints;
use OpenApi\Builder;
use OpenApi\Builder\Mode;
use OpenApi\Builder\Result;
use OpenApi\Spec as OA;
use OpenApi\Specification;
/**
* Routes registered imperatively — no attribute anywhere, so nothing to scan.
*
* @param array<string, class-string> $routes path => the class documenting the response
*/
function buildFromRoutes(array $routes): Result
{
return (new Builder())
->setMode(Mode::SPEC)
->addSource(new \ReflectionClass(Pet::class))
->withSpecification(function (Specification $specification) use ($routes): void {
$specification->add(new OA\Info(title: 'Registered routes', version: '1.0.0'));
foreach ($routes as $path => $model) {
$specification->add(new OA\Operation\Get(path: $path, responses: [
new OA\Response(response: 200, description: 'OK', content: [
new OA\MediaType\Json(schema: new OA\Schema(ref: $model)),
]),
]));
}
})
->build();
}That produces:
openapi: 3.1.0
info:
title: 'Registered routes'
version: 1.0.0
paths:
/pets:
get:
operationId: a258179ad504a1eacaafc2ef43cbd7ba
responses:
200:
description: OK
content:
application/json:
schema:
$ref: '#/components/schemas/Pet'
components:
schemas:
Pet:
type: object
properties:
name:
type: string
default: ''
age:
type: integer
default: 0
required:
- name
- ageThe pipeline reads a reflector, not a source file, so where an attribute came from does not matter. A contribution that carries one is treated as the assembler's own: an operation given its ReflectionMethod takes summary, description and operation id from the method, and a parameter given its ReflectionParameter takes its name, type and whether it is required. A router that knows [UserController::class, 'index'] for a route can hand all of that over. What has no reflector anywhere, an entity registry or a serializer's configuration, goes in bare, and this is the only way it gets in.
An augmenter can also add() attributes, but it runs after resolution, so a $ref inside what it adds stays unresolved. Use an augmenter to enrich what is there, and this to put something there.
Once added, a contribution looks like any scanned attribute. If a later step needs to tell yours apart, such as a merger deciding precedence or an augmenter that should leave them alone, mark them as you add them with setMeta() under a key you own. Read it back with getMeta() there. Keys that are the name of a swagger-php class belong to swagger-php.
Resolvers
Seeding from reflectors means the specification can name a class that was never a source: a controller is added, one of its $refs points at a DTO, and nothing ever collected the DTO. Each such name goes to the resolver step.
Resolver\Reflection is registered by default and covers the ordinary case. The class exists and carries spec attributes, so it is collected by reflection and the reference resolves. It returns false when the class yielded no component, and an unresolved reference is reported rather than silently dropped.
A resolver is asked about a key, the class plus any qualifiers. It implements KeyResolverInterface, and Resolver\Concerns\KeyResolverTrait supplies the method for a class name alone. Describing a class more than once has what a key with qualifiers is for.
Builder::withResolver() hands the resolver list to a callable. Resolvers are tried in order and the first success wins, so one added after the default is handed the classes the default could not resolve. What it makes of them is up to whoever writes it. Deriving a schema from a class's public properties and PHP types is one option, and would let a DTO carrying no spec attributes appear in the document. insert() places a resolver ahead of the default rather than after it.
A resolver implementing LoggerAwareInterface is given the logger passed to the Builder constructor.
Augmenters
An augmenter runs after assembly, over the whole Specification, and fills in what the attributes left out:
<?php declare(strict_types=1);
namespace OpenApi\Snippets\Guide\ExtensionPoints;
use OpenApi\Augmenter\Group;
use OpenApi\Utils\PipeInterface;
final class TagFromController implements PipeInterface
{
public function __invoke(mixed $specification): mixed
{
foreach ($specification->operations as $operation) {
$reflector = $operation->getReflector();
if ($reflector instanceof \ReflectionMethod) {
$controller = $reflector->getDeclaringClass()->getShortName();
$operation->tags ??= [preg_replace('/Controller$/', '', $controller) . 's'];
}
}
return $specification;
}
public function group(): string|\BackedEnum
{
return Group::Augment;
}
}It does not have to implement anything. Any callable taking the Specification and returning it will do. PipeInterface exists to declare a phase, and a pipe without it lands in the pipeline's default group:
$pipeline->add(fn (Specification $spec) => $spec);Specification carries the views an augmenter needs over the assembled tree: getWalker() for traversal, buildComponentIndex() to resolve a $ref, and buildPathItemHierarchy() for the PathItems governing a class. Path-level metadata has to be read through it, since a class without its own PathItem is governed by its ancestors'.
An augmenter that needs one of the builder's services declares it with an interface from OpenApi\Contracts, and the builder hands the service over when the build runs, as the pipeline does a logger to a LoggerAwareInterface. ComponentNamerAwareInterface gets the component namer, so a name the augmenter works out matches the one Names gives. AttributeFactoryAwareInterface gets the attribute factory, with its translators. TypeResolverAwareInterface gets the type resolver, so a PHP type the augmenter reads matches the one Types reads. Builder::setTypeResolver() replaces it. A resolver added through withResolver() and a merger added through withMergers() get them the same way. What the builder hands over replaces what was set directly.
Builder::withAugmenters() hands the pipeline to a callable, which adds, replaces or removes. Phases run resolve → reduce → augment, and within a phase in registration order. The enum is OpenApi\Augmenter\Group. The Augmenters reference lists the built-in pipeline and what each phase is for.
Component names
A component declared on a class without a name is, by default, named after the class, by its short name. Two classes that share a short name, such as App\Dto\User\CreateDTO and App\Dto\Order\CreateDTO, then claim one name. The document keeps one of them and logs a warning.
The name comes from a component namer, set with Builder::setComponentNamer(). ComponentNamer\ShortName is the default. ComponentNamer\QualifiedName uses the whole class name:
use OpenApi\ComponentNamer\QualifiedName;
$builder->setComponentNamer(new QualifiedName(strip: 'App\Dto\\'));The two classes above become User.CreateDTO and Order.CreateDTO. strip removes a namespace prefix first, and separator joins what is left. It defaults to ., and an empty string runs the segments together as UserCreateDTO. A component with an explicit name keeps it.
A namer of your own implements OpenApi\Contracts\ComponentNamerInterface. Its name() receives the component and returns its name, or null to leave the component inline. It can be asked about one component more than once, before its name is set and after, so the answer depends on the component alone. Extending ShortName and overriding className() changes how a class is named and keeps the names of an #[OA\Components] holder's members.
On the command line, --component-namer picks a namer by class: QualifiedName for a built-in one, or the fully qualified name of your own, loaded with --bootstrap. It is built without arguments, so other settings need a subclass.
Mode difference
The namer names components in spec and hybrid mode. Classic mode does not use it.
Describing a class more than once
A class name stands for one component. A $ref given as the class, and a property typed with it, point at the component declared on that class. A producer can describe one class more than once, for example once per serialization group. Each description is a component with a name of its own, and a key tells them apart.
A key is the class plus qualifiers. A component declared on a class has the plain key, the class alone, unless setComponentKey() gives it another. Each further description gets a ComponentKey with qualifiers, and a Schema\Ref with the same qualifiers references it:
<?php declare(strict_types=1);
namespace OpenApi\Snippets\Guide\ExtensionPoints;
use OpenApi\Builder;
use OpenApi\Builder\Mode;
use OpenApi\Builder\Result;
use OpenApi\Spec as OA;
use OpenApi\Specification;
use OpenApi\Specification\ComponentKey;
final class Author
{
public int $id;
public string $name;
public string $email;
}
#[OA\Schema]
final class Article
{
#[OA\Property]
public int $id;
#[OA\Property]
public Author $author;
#[OA\Schema\Ref(ref: Author::class, qualifiers: ['groups' => ['summary']])]
public Author $reviewer;
}
/**
* `Author` described twice: in full, and with only the fields of its `summary` group.
*/
function buildWithDescriptions(): Result
{
return (new Builder())
->setMode(Mode::SPEC)
->addSource(new \ReflectionClass(Article::class))
->withSpecification(function (Specification $specification): void {
$specification->add(
new OA\Info(title: 'Articles', version: '1.0.0'),
new OA\Operation\Get(path: '/articles', responses: [
new OA\Response(response: 200, description: 'OK', content: [
new OA\MediaType\Json(schema: new OA\Schema(ref: Article::class)),
]),
]),
);
$full = new OA\Schema(properties: [
'id' => OA\Type::Integer,
'name' => OA\Type::String,
'email' => OA\Type::String,
]);
$summary = new OA\Schema(properties: [
'id' => OA\Type::Integer,
'name' => OA\Type::String,
]);
$summary->setComponentKey(new ComponentKey(Author::class, ['groups' => ['summary']]));
foreach ([$full, $summary] as $schema) {
$specification->add($schema->setReflector(new \ReflectionClass(Author::class)));
}
})
->build();
}That produces:
openapi: 3.1.0
info:
title: Articles
version: 1.0.0
paths:
/articles:
get:
operationId: cc5d455029f62da08111193b6f35384f
responses:
'200':
description: OK
content:
application/json:
schema:
$ref: '#/components/schemas/Article'
components:
schemas:
Article:
type: object
properties:
id:
type: integer
author:
$ref: '#/components/schemas/Author'
reviewer:
$ref: '#/components/schemas/Author-groups.summary'
required:
- id
- author
- reviewer
Author:
type: object
properties:
id:
type: integer
name:
type: string
email:
type: string
Author-groups.summary:
type: object
properties:
id:
type: integer
name:
type: stringauthor is typed with the class, so it references the plain key, Author. reviewer asks for the summary group and gets Author-groups.summary.
A description without an explicit name is named after its key. The namer names the class, and each qualifier follows in the key's order, with - before it, . between its name and its values, and _ between values. ['groups' => ['summary', 'admin']] gives Author-groups.admin_summary. A value that names a class is named like that class. Any other character a component name does not allow becomes _. Where that is not readable enough, give the description a name of its own with component:.
Qualifiers map a name to a scalar or a list of scalars, one level deep. Their order does not matter, and a list is compared as a set, so ['groups' => ['summary', 'admin']] and ['groups' => ['admin', 'summary']] are one key. A value whose order matters, such as type arguments, goes in as one string.
A key names one component per bucket. Where two components share a key, a $ref given as that key resolves to neither and logs a warning naming both. A property typed with the class is left without a type, with a warning. Give all but one of them qualifiers, or reference the one you mean by its name, #/components/schemas/Author-groups.summary.
Two keys can still end up with one name, for example two Author classes in different namespaces with the same qualifiers. The document keeps the last one and logs a warning naming both keys. QualifiedName or an explicit name tells them apart.
In an augmenter, ComponentIndex::findByKey() returns the component a key names in a bucket, and findAllByKey() returns every one.
A description can also be made when something asks for it. A Schema\Ref with qualifiers that no component has goes to the resolvers, with its key. A resolver that knows the class's groups adds a schema for them, with that key, and the reference finds it. Resolver\Reflection describes a class once, from its own attributes, so it leaves a key with qualifiers to the next resolver.
Mode difference
Keys are read in spec and hybrid mode. Classic mode does not use them.
Mergers
Two attributes can claim one key. Two operations on the same path and method, or two schemas named Pet: a scan finds one, a withSpecification() hook contributes the other, an inheritance clone makes a third. Something has to decide which of them the document holds. Until it does, the compiler decides by accident. It writes each into a PHP array and keeps whichever it wrote last.
Augmenter\Merge decides instead, through a chain of mergers. A merger says what makes two attributes the same one, and what the survivor is. Below, an operation the scan already described keeps the key against one a hook contributed and marked as its own:
use OpenApi\Contracts\AttributeInterface;
use OpenApi\Contracts\MergerInterface;
use OpenApi\Spec as OA;
final class MyOperationMerger implements MergerInterface
{
public function supports(string $class): bool
{
return is_a($class, OA\Operation::class, true);
}
public function identity(AttributeInterface $attribute): ?string
{
return $attribute->path !== null && $attribute->method !== null
? $attribute->method . ' ' . $attribute->path
: null;
}
public function merge(AttributeInterface $earlier, AttributeInterface $later): AttributeInterface
{
return $later->getMeta(self::class, false) ? $earlier : $later;
}
}Builder::withMergers() registers it. They are tried in order and the first to claim a type handles it. Merge\LastWins claims everything, keeps the later entry and warns with both locations, so it ships last:
use OpenApi\Merge;
use OpenApi\Utils\TypedList;
$builder->withMergers(fn (TypedList $mergers) => $mergers->insert(
new MyOperationMerger(),
Merge\LastWins::class,
));identity() returning null means the attribute never merges and passes through: that is how servers and security requirements stay as they are, being positional rather than keyed.
$earlier and $later are in producer order, which is the only thing the pipeline guarantees, so return $later is last-wins. Precedence beyond that order is a policy the core does not hold. A package that needs to recognise its own attributes marks them as it creates them, with $operation->setMeta(MyOperationMerger::class, true), and reads that back in merge(). meta is keyed by whoever writes to it. Nothing in swagger-php writes or reads it.
To combine two operations for the same path and method instead of choosing between them, register the shipped Merge\Operations the same way, ahead of Merge\LastWins. It is not on by default. It fills each field one of them leaves unset from the other, so a contributed route and an attribute adding responses to it become one operation. What both set is decided by a Merge\Mode: fold into the later (the default), into the earlier, or take only what does not overlap. A callable can choose the mode per pair, which is how a producer's mark decides precedence without a merger of its own:
use OpenApi\Contracts\AttributeInterface;
use OpenApi\Merge;
new Merge\Operations(
fn (AttributeInterface $earlier, AttributeInterface $later): Merge\Mode
=> $later->getMeta(MyProducer::class) ? Merge\Mode::First : Merge\Mode::Last,
);A parameter that is only a ref folds with an inline one of the same name and location, since the Parameters augmenter gives it the name and location of its component first. The reference is what survives.
See the Mergers reference for how each field folds.
The pass runs over the Specification's own collections, where attributes sharing a key come from different places. A duplicate key inside one attribute, such as two 200 responses in one operation, is two entries one author wrote in one place, and the compiler is left to keep the last of them.
Compilers
Builder::setCompiler() replaces the compiler that turns the Specification into a document. One is resolved from the target version otherwise, so this is for output a shipped compiler does not produce.
The classic escape hatch
Builder::withGenerator() configures the classic Generator, which is the whole pipeline in classic mode and the scanning pass in hybrid. The callable receives a default Generator and may configure it in place or return another. Spec mode has no Generator, so the hook is never called there.
In hybrid, the Generator arrives with an empty processor pipeline, so a processor added here is the only one that runs, and it sees the annotation instances as scanned: properties are not yet attached to their schemas, and inheritance has not yet produced any allOf. The bridge and the augmenters do that work afterwards. A processor that only reads or changes what the author wrote works in hybrid. One that depends on that structure belongs in an augmenter.
Plugins
A library that ships translators, resolvers or augmenters can wire them into a builder itself, with a plugin. A project or an integration then names the plugin, rather than repeating the wiring.
A plugin implements OpenApi\Contracts\PluginInterface. Its configure() receives the builder's hooks and the plugin's settings:
use OpenApi\Builder\Hooks;
use OpenApi\Contracts\PluginInterface;
use OpenApi\Utils\AttributeFactory;
use OpenApi\Utils\TypedList;
class RoutePlugin implements PluginInterface
{
public function configure(Hooks $hooks, array $config): void
{
$hooks->withAttributeFactory(fn (AttributeFactory $factory) => $factory->withTranslators(
fn (TypedList $translators) => $translators->add(new RouteTranslator())
));
}
}Builder::withPlugins() takes a list. Each entry is a plugin class, a plugin class mapped to its settings, or a plugin instance:
$builder->withPlugins([
RoutePlugin::class,
SchemaPlugin::class => ['groups' => ['read']],
]);A class is created without arguments, so the list can live in a configuration file. Plugins run when withPlugins() is called, in the order given, and that order decides where each plugin's translators, resolvers and mergers sit. Calling it again runs the new plugins after the earlier ones. An invalid entry, such as a class that is not a plugin, is skipped and logged as an error.
A plugin that throws from configure() stops withPlugins(). What it added before throwing stays in the builder, which is then not fit to build.
The hooks are withResolver(), withAugmenters(), withMergers(), withAttributeFactory(), withSpecification() and withDocument(). Each one configures what the builder holds. A plugin could also remove or replace what is there, for example drop Cleanup, but plugins build on one another when each adds its own. A plugin cannot set the component namer, the type resolver, the compiler or the version: those are for whoever runs the build to choose. A library that ships a component namer documents it, and the project sets it with setComponentNamer().
A plugin throws for settings it cannot use. To report what it can work around, such as an optional package that is not installed, it implements Psr\Log\LoggerAwareInterface and gets the builder's logger before configure(). What it logs there counts towards the next build's result, so an error makes Result::isValid() false. Translators, resolvers, augmenters and mergers a plugin adds get the build's logger the same way the builder's own do, when they implement LoggerAwareInterface.
The hooks act on the spec pipeline, so in classic mode nothing a plugin adds is used, and the build logs a warning saying so.
On the command line, --plugin names a plugin class and can be repeated. The plugin is created without settings.
Shipping a plugin with a package
A package declares its plugins in its composer.json, as a list of class names:
{
"extra": {
"swagger-php": {
"plugins": ["Vendor\\Package\\OpenApiPlugin"]
}
}
}Builder::withDiscoveredPlugins() reads these from Composer's installed.json, and adds each plugin without settings. Nothing is discovered unless it is called, so installing a package does not change a document by itself. Each plugin found is logged at info. Packages installed for development count as well.
The order does not depend on how Composer writes the file. A package's plugins run after those of the packages it requires, and packages otherwise go by name. A plugin that configured the builder already is left out, so a project gives a discovered plugin settings or a position by listing it in withPlugins() first:
$builder
->withPlugins([SchemaPlugin::class => ['groups' => ['read']]])
->withDiscoveredPlugins(exclude: [AuditPlugin::class]);A plugin named in exclude is left out. One listed in withPlugins() after discovery added it is skipped, with a warning. Class names compare as PHP compares them, ignoring case and a leading backslash.
By default the installation read is the one swagger-php itself was loaded from. A tool that documents another project can pass that project's vendor directory as vendorDir. The plugins found there still have to be autoloadable.
On the command line, --discover-plugins adds the discovered plugins after the --plugin ones. It reads the installation openapi runs from, so a project's plugins are found by running the project's own vendor/bin/openapi. Run from a global install in a project with its own vendor directory, it warns.
Putting it together
RouteTranslator and TagFromController from above, wired onto a builder seeded from a reflector rather than a directory:
<?php declare(strict_types=1);
namespace OpenApi\Snippets\Guide\ExtensionPoints;
use OpenApi\Builder;
use OpenApi\Builder\Mode;
use OpenApi\Builder\Result;
use OpenApi\Utils\AttributeFactory;
use OpenApi\Utils\Pipeline;
use OpenApi\Utils\TypedList;
function buildSpec(): Result
{
return (new Builder())
->setMode(Mode::SPEC)
->addSource(new \ReflectionClass(PetController::class))
->withAttributeFactory(fn (AttributeFactory $factory) => $factory->withTranslators(
fn (TypedList $translators) => $translators->add(new RouteTranslator())
))
->withAugmenters(fn (Pipeline $augmenters) => $augmenters->add(new TagFromController()))
->build();
}Given a controller carrying #[Route(path: '/pets')] and an #[OA\Response], that produces:
openapi: 3.1.0
info:
title: Pets
version: '1.0'
paths:
/pets:
get:
tags:
- Pets
operationId: list
responses:
200:
description: OKWhere the boundaries are
Some things are deliberately not extension points. Each has a reason, and each has an alternative.
Property types are not widened for downstream convenience. The strong typing is what makes the DTOs worth having. Metadata that only means something to one integration belongs in an Attachable when it is declared in source next to the attribute it describes, and in setMeta() when code attaches it along the way. It does not belong in a widened $ref: string|object. Neither reaches the generated document.
There is no framework-specific code, and no plans for any. Translators, contributions, augmenters and attachables are the contract. Anything a framework needs can be built from them, outside this repository.
There are no events or listeners. The pipeline is deterministic and reads top to bottom, which is what makes a wrong document traceable to the step that produced it. Event ordering is not obvious from reading, and it is harder to test.
Assembler internals stay internal. collect() is the contract. How it resolves nesting is free to change, and has.
Going further
- Builder reference: every hook on its own, with signatures
- Augmenters reference: the built-in pipeline and its phases
- Architecture: how the stages fit together
- Spec pipeline internals: slot maps, resolution, and the rules a new attribute has to follow