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; 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.
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.
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.
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);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.
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 โ 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.
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, not in a widened $ref: string|object.
There is no framework-specific code, and no plans for any. Translators, 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