Search by

radebatz / openapi-introspector

DerManoMann

Documents the routes your Laravel or Slim application already has: reads them from the framework and contributes them to the OpenAPI document swagger-php builds, so nothing is declared twice. The reverse of openapi-router.

Package info

github.com/DerManoMann/openapi-introspector

pkg:composer/radebatz/openapi-introspector

Statistics

Installs: 1

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

1.0.0 2026-10-11 20:43 UTC

This package is auto-updated.

Last update: 2026-10-11 21:38:29 UTC


README

Build Status Coverage Status License: MIT

Introduction

Your framework already knows your API. Every route has a method, a path, path parameters with their constraints, and a handler whose docblock and signature describe it. Documenting that with swagger-php usually means writing it all a second time, as attributes, and then keeping the two in step by hand. The documentation drifts the first time a route changes and its attribute does not.

openapi-introspector reads the routes from the framework instead, and contributes them to the OpenAPI document swagger-php builds:

  • Routes you have not documented still appear. Each gets its path parameters, with a route constraint as the parameter's pattern. Where the route dispatches to a class method, it also gets a summary, description and parameters from that method, exactly as if it had been scanned.
  • Attributes you have written still win on description. They fold into the route's operation field by field, so you add them only for what a router cannot know: responses, request bodies, a better description.
  • Documentation without a route is reported. An inventory lists every method and path, and says which are documented but not routed. That is a defect the document alone cannot show.

This is the reverse of openapi-router, which builds the routes from the attributes. The two are complements: openapi-router makes the document the source of the routes, the introspector makes the routes the source of the document, and a project can use both.

Spec attributes only. The package works inside swagger-php's spec pipeline, through Builder::withSpecification(). Classic mode assembles no specification, so there is nothing here for it to contribute to.

Supported frameworks:

Requirements

  • PHP 8.2 or higher
  • zircote/swagger-php 6.13 or higher, for the PathItem chain helpers and ComponentIndex::parameterKey()

Usage

use OpenApi\Builder;
use OpenApi\Builder\Mode;
use Radebatz\OpenApi\Introspector\Adapters\LaravelAdapter;
use Radebatz\OpenApi\Introspector\Introspector;

$introspector = (new Introspector())
    ->addAdapter(new LaravelAdapter($router));

$result = $introspector
    ->register((new Builder())->setMode(Mode::SPEC)->addSource(__DIR__ . '/app'))
    ->build();

$inventory = $introspector->inventory();

An adapter yields one partial operation per method and path the framework routes. Where the route dispatches to a class method, the operation carries that method's reflector, and the pipeline derives the summary, the description and the parameters from it as it would for a scanned one. A closure route is contributed bare and gets an id from its method and path.

Where the routes come from

Hand the adapter the route table once the application has registered its routes.

Laravel: app(Router::class), or Route::getRoutes() through the facade, after the application has booted. An artisan command qualifies. A cached route table works the same, because CompiledRouteCollection rebuilds Route objects when asked for them.

Slim: $app->getRouteCollector(), after the code that defines the routes has run and before $app->run().

Choosing the routes

The whole table is rarely what a document should describe. A Laravel application also routes Sanctum's CSRF cookie, Telescope, Horizon, Ignition and L5-Swagger's own documentation pages, and every route the adapter sees becomes an Introspected entry in the inventory. Both adapters also take any iterable of routes, so choosing them is an array_filter before the adapter sees them.

Leaving out vendor routes needs no convention from the project. A route is vendor when its handler is defined under vendor/, which is the test php artisan route:list --except-vendor applies:

use Illuminate\Routing\RedirectController;
use Illuminate\Routing\Route;
use Illuminate\Routing\Router;
use Illuminate\Routing\ViewController;
use Radebatz\OpenApi\Introspector\Adapters\LaravelAdapter;

$vendor = base_path('vendor');

$routes = array_filter(
    app(Router::class)->getRoutes()->getRoutes(),
    static function (Route $route) use ($vendor): bool {
        $uses = $route->getAction('uses');

        if ($uses instanceof \Closure) {
            $file = (new \ReflectionFunction($uses))->getFileName();
        } elseif (is_string($uses) && !str_contains($uses, 'SerializableClosure')) {
            $class = ltrim($route->getControllerClass(), '\\');
            // Route::redirect() and Route::view() are the application's own routes
            if (in_array($class, [RedirectController::class, ViewController::class], true)) {
                return true;
            }
            $file = (new \ReflectionClass($class))->getFileName();
        } else {
            return true;
        }

        return !str_starts_with((string) $file, $vendor);
    },
);

$adapter = new LaravelAdapter($routes);

Narrower tests work the same way. In Laravel, Route::middleware() returns the route's middleware, its group included, so the api group is one comparison. In Slim, a group's prefix is part of each route's pattern:

use Illuminate\Routing\Route;
use Illuminate\Routing\Router;
use Slim\Interfaces\RouteInterface;

// Laravel: the api middleware group
$routes = array_filter(
    app(Router::class)->getRoutes()->getRoutes(),
    static fn (Route $route): bool => in_array('api', $route->middleware(), true),
);

// Slim: everything under /api
$routes = array_filter(
    $app->getRouteCollector()->getRoutes(),
    static fn (RouteInterface $route): bool => str_starts_with($route->getPattern(), '/api'),
);

Generating for L5-Swagger

L5-Swagger builds its document itself, with no hook for a contribution, so for now the integration is an artisan command that writes the file L5-Swagger serves:

use Illuminate\Console\Command;
use Illuminate\Routing\Router;
use OpenApi\Builder;
use OpenApi\Builder\Mode;
use Radebatz\OpenApi\Introspector\Adapters\LaravelAdapter;
use Radebatz\OpenApi\Introspector\Introspector;
use Radebatz\OpenApi\Introspector\Status;

final class GenerateApiDocs extends Command
{
    protected $signature = 'api-docs:generate';

    protected $description = 'Build the OpenAPI document from the attributes and the routes';

    public function handle(Router $router): int
    {
        $introspector = (new Introspector())->addAdapter(new LaravelAdapter($router));

        $introspector
            ->register((new Builder())->setMode(Mode::SPEC)->addSource(app_path()))
            ->build()
            ->saveAs(storage_path('api-docs/api-docs.json'));

        foreach ($introspector->inventory()->entries(Status::Unrouted) as $entry) {
            $this->warn("Documented but not routed: {$entry->method} {$entry->path}");
        }

        return self::SUCCESS;
    }
}

storage_path('api-docs/api-docs.json') is L5-Swagger's default docs path and docs_json file. Keep generate_always off, which is the default: when it is on, L5-Swagger regenerates the document on every request and overwrites this one. Pass the command a filtered list of routes, as above, rather than the whole router.

What wins

The framework wins on existence and on dispatch facts: which paths and methods exist, and what the router will match. Attributes win on description. In practice:

  • Attributes on a route's handler add to what the route gives. A handler that serves one route and carries only #[OA\Response] gets the route's operation for the response to join. An operation the attributes declare without a path or method gets the route's, where one route matches its method. This needs the handler's class in the builder's sources, since only those are read for attributes.
  • Where both describe a path parameter, it is folded by name and location. The attribute's description wins. An attribute parameter with no schema takes the route's, type included. One with a schema keeps it, except for pattern: the route's constraint is what the router matches, so it replaces the attribute's. A route with no constraint leaves the attribute's pattern as it is. A parameter referencing a component is left as it is, and so is one the controller declares on its #[OA\PathItem(parameters: ...)], or a parent class does. The route adds no operation-level copy that would replace it, unless the attribute has its own to fold into.
  • An operation declared on a method other than the handler, for the route's method and path, is completed the same way. The attribute operation is the base, so one that only adds a tag or a description still gets the route's path parameters.
  • A route nothing describes is contributed by the adapter.
  • A controller's #[OA\PathItem(prefix: ...)] is applied once. The route's full path already contains it, so it is taken off before the pipeline adds it again. A route whose path does not start with the prefix is left as it is, and still gets the prefix in front.
  • A handler serving several routes, such as a Laravel route with an optional parameter, folds only the route its attribute names by method and path. The others are contributed whole, and a bare #[OA\Response] on such a handler joins none of them.
  • A route with several methods, such as Laravel's Route::match(['get', 'post'], ...), is one route per method, all served by the same handler. An #[OA\Operation] that sets method folds into the route for that method. Attributes that name no method, such as a bare #[OA\Response] or an #[OA\Operation] without method, join none of them and are lost. To describe every method, declare one operation per method, each with its own responses.
  • A route parameter's constraint, such as Laravel's where('id', '[0-9]+') or Slim's {id:[0-9]+}, becomes the parameter's pattern. The type is integer only where the pattern admits nothing else, and string otherwise.
  • HEAD is dropped as an artefact of GET, and a Laravel fallback route is not an endpoint.
  • A route name becomes the operationId only when the adapter is asked to, with nameAsOperationId: true, because that is a convention and not a fact.

The inventory

The second output. One entry per method and path, with a status:

Status Meaning
Matched attributes describe it and a route serves it
Introspected a route serves it and nothing else describes it; the adapter's operation went in
Unrouted attributes describe it and no route serves it

Unrouted is the case worth acting on: a documented endpoint that is not routed is a defect in one of the two, and the document alone cannot show it.

Extending

Both adapters take a Routing\Handler, which resolves a route's handler to a method reflector, and a Routing\Constraint, which turns a constraint into a schema. Each has a default, and each is a plain class to extend for a project's own conventions.

License

The MIT License (MIT). See LICENSE.