Skip to content

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
<?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
<?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():

php
$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
<?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 you the assembled Specification before the resolver runs. Add attributes to it and they go through the rest of the pipeline as scanned ones do:

php
<?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:

yaml
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
        age:
          type: integer
      required:
        - name
        - age

The 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 โ€” a merger deciding precedence, an augmenter that should leave them alone โ€” mark them as you add them with setMeta() under a key you own, and read it back with getMeta() there.

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
<?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:

php
$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 โ€” which is what path-level metadata has to be read through, since a class without its own PathItem is governed by its ancestors'.

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.

Mergers โ€‹

Two attributes can claim one key. Two operations on the same path and method, 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, and 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:

php
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, so Merge\LastWins โ€” which claims everything, keeps the later entry and warns with both locations โ€” ships last:

php
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 โ€” 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 โ€” $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.

The pass runs over the Specification's own collections, where the halves come from different places. A duplicate key inside one attribute โ€” 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 โ€” 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
<?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:

yaml
openapi: 3.1.0
info:
  title: Pets
  version: '1.0'
paths:
  /pets:
    get:
      tags:
        - Pets
      operationId: list
      responses:
        200:
          description: OK

Where 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 โ€” not 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 โ€‹