Introduction

Upgrading to v2

PHP autodoc v2 requires PHP 8.5 and comes with a new documentation viewer, a reworked extension API and a few renamed config options. This page lists every breaking change and how to update your code.

How much of it applies to you depends on your setup:

  • Everyone should read Configuration changes.
  • If you serve the docs page from your own route, read Documentation viewer. Laravel users with the default setup can skip it - the package registers the new route automatically.
  • If you wrote custom extensions, read Extension API and Type API.
  • Generated schemas and TypeScript output change slightly in v2 - see Output changes if you keep exported files under version control.

Requirements

  • PHP 8.5 or higher.
  • For Laravel projects: 421c/autodoc-laravel v2, which depends on 421c/autodoc-php v2.

CLI commands

The separate plain-PHP Composer binaries are replaced by one autodoc executable with subcommands. Laravel Artisan commands use the same names with colons:

v1v2
vendor/bin/openapi-export [workspaceKey] --config=...vendor/bin/autodoc openapi [workspaceKey] --config=...
vendor/bin/ts-sync [workingDirectory] --config=...vendor/bin/autodoc ts [workingDirectory] --config=...
vendor/bin/autodoc-debug [workingDirectory] --config=...vendor/bin/autodoc debug [workingDirectory] --config=...
php artisan autodoc:export [workspace]php artisan autodoc:openapi [workspace]
php artisan autodoc:ts-sync [working_directory]php artisan autodoc:ts [working_directory]
php artisan autodoc:debug [working_directory]php artisan autodoc:debug [working_directory]

The plain-PHP commands require --config=<path>.

Configuration changes

ui.hide_try_it is now ui.try_it.enabled

Replace hide_try_it with try_it.enabled and invert its value. The default behavior is unchanged: ‘Try it’ is enabled.

// v1
'ui' => [
    'hide_try_it' => true,
],

// v2
'ui' => [
    'try_it' => [
        'enabled' => false,
        'proxy_url' => '...',
    ],
],

ui.theme defaults to system

ui.theme accepts 'system', 'light', and 'dark'. The new default, 'system', follows the operating-system preference. This setting controls only the initial theme; a visitor’s saved choice takes precedence.

openapi.show_routes_as_titles removed

In v1 this option rewrote the exported OpenAPI document itself: each operation's summary was replaced with the route URI and the original summary moved into description. That affected every consumer of the JSON, not just the docs viewer.

In v2, exports preserve the original summary and description. Configure route labels in the viewer instead:

'ui' => [
    'sidebar' => [
        'routes' => [
            'show_path' => true,
            'show_title' => false,
            'show_method' => true,
            'show_path_above_title' => true,
        ],
    ],
],

Remove openapi.show_routes_as_titles from your config and use these toggles to get the same presentation.

New options in v2

v2 also adds these configuration options:

  • workspaces_json_dir - a directory of <key>.json files, each holding one workspace config. They are merged under the inline workspaces array, and inline entries win on key conflicts.
  • Each workspace can now override individual values from the global ui block, for example to use its own logo or wiki pages.
  • A wiki page entry can use an absolute path instead of a url. Such pages are served through the docs route, scoped to the workspace, and the file path is never exposed to the client.

Documentation viewer

The docs page no longer loads Stoplight Elements from a CDN. v2 renders with a self-contained viewer bundled inside the package, and its assets are streamed directly from vendor/ - nothing is copied into your public/ directory.

Laravel users with the default route do not need to make this change. The package registers the documentation route at laravel.url, including token-protected workspaces.

One route instead of two

In v1 you defined two routes: one echoing Workspace::getJson() and one calling DocViewer::renderPage(). In v2 a single catch-all GET route serves everything - the HTML page, openapi.json and the viewer assets.

Create the viewer with new DocViewer($config, baseUrl: ...) and pass the part of the URI after the base URL to handle():

// e.g. mounted at /docs - match both `/docs` and `/docs/...`
$config = new \AutoDoc\Config(require '/path/to/config/autodoc.php');

new \AutoDoc\DocViewer($config, baseUrl: '/docs')->handle($path)->emit();

$path is '' for the HTML page, openapi.json for the schema, or assets/... for viewer files. The old field-by-field constructor (title, openApiUrl, theme, ...) and the DocViewer::make() factory are gone.

handle() returns a response object

In v1 the viewer methods wrote straight to the output stream with echo and header(). Under a framework that sends its own response this could fail with "Cannot modify header information - headers already sent".

In v2, DocViewer::handle() returns an AutoDoc\DocViewerResponse with status, headers, and either body or filePath. It does not send output itself. In plain PHP, call emit(). In a framework, use those properties to build a native response.

Route::get('/docs/{path?}', function (string $path = '') {
    $config = new \AutoDoc\Config(require config_path('autodoc.php'));

    $result = new \AutoDoc\DocViewer($config, baseUrl: '/docs')->handle($path);

    return $result->filePath !== null
        ? new \Symfony\Component\HttpFoundation\BinaryFileResponse($result->filePath, $result->status, $result->headers)
        : new \Illuminate\Http\Response($result->body ?? '', $result->status, $result->headers);
})->where('path', '.*');

Extension API

Call extensions receive a context object

FuncCallExtension, MethodCallExtension and StaticCallExtension now get a single context parameter instead of separate $node, $scope and $argTypes arguments:

  • FuncCallContext - functionName (pre-extracted, null for dynamic calls), argTypes, node, scope
  • MethodCallContext - methodName, argTypes, getVarType() (lazy-resolved and cached), node, scope
  • StaticCallContext - className (pre-resolved), methodName, argTypes, node, scope
// v1
use AutoDoc\Analyzer\ArgumentList;
use AutoDoc\Analyzer\Scope;
use AutoDoc\DataTypes\Type;
use AutoDoc\Extensions\MethodCallExtension;
use PhpParser\Node\Expr\MethodCall;

class MyExtension extends MethodCallExtension
{
    public function getReturnType(MethodCall $methodCall, Scope $scope, ArgumentList $argTypes): ?Type
    {
        if (! $methodCall->name instanceof \PhpParser\Node\Identifier) {
            return null;
        }

        $methodName = $methodCall->name->name;
        $varType = $scope->resolveType($methodCall->var);

        // ...
    }
}
// v2
use AutoDoc\Analyzer\MethodCallContext;
use AutoDoc\DataTypes\Type;
use AutoDoc\Extensions\MethodCallExtension;

class MyExtension extends MethodCallExtension
{
    public function getReturnType(MethodCallContext $call): ?Type
    {
        $methodName = $call->methodName;
        $varType = $call->getVarType();

        // ...
    }
}

ThrowExtension follows the same pattern with a ThrowContext - the thrown type is resolved lazily on the first getThrownType() call:

// v2
use AutoDoc\Analyzer\ThrowContext;
use AutoDoc\Extensions\ThrowExtension;

class MyThrowExtension extends ThrowExtension
{
    public function getReturnType(ThrowContext $throw): ?Type
    {
        $thrownType = $throw->getThrownType();

        // Scope is available as $throw->scope, the raw node as $throw->node

        return null;
    }
}

ClassExtension and OperationExtension signatures are unchanged.

Request capture moved to handleSideEffect

Replace the call extension’s getRequestType() with handleSideEffect(). Inside it, call $call->setRequestType() instead of returning the request type.

// v1
public function getRequestType(MethodCallContext $call): ?Type
{
    if ($call->methodName === 'validate') {
        return $call->argTypes->get(0)->unwrapType($call->scope->config);
    }

    return null;
}
// v2
public function handleSideEffect(MethodCallContext $call): void
{
    if ($call->methodName === 'validate') {
        $call->setRequestType($call->argTypes->get(0)->unwrapType($call->scope->config));
    }
}

handleSideEffect() runs once per call node, before getReturnType(). It does not run during return-type-only analysis. It also runs for discarded calls such as func(); and Foo::bar();, which did not reach extensions in v1.

Two related renames:

  • Scope::withoutRequestBodyCapture() is now Scope::withoutSideEffects().
  • ClassExtension::getRequestType(PhpClass) is unchanged because a form request class directly describes the request body.

Your extensions now run before the built-ins

Custom extensions now run before built-ins, and the first non-null result wins. Return null for calls that should still use a built-in.

Nullsafe calls reach MethodCallExtension

Extensions are now also invoked for ?-> calls. MethodCallContext::$node is typed MethodCall|NullsafeMethodCall, so add a guard if your extension needs a concrete MethodCall.

Type API

These changes matter if your extensions work with Type objects directly.

Config is required everywhere

Config was optional in v1. In v2, these Type methods require it:

  • Type::toSchema(Config $config) and every subclass override
  • Type::unwrapType(), Type::deepResolve(), Type::removeNull()
  • Type::isSubTypeOf($other, $config)
  • ArrayType::convertShapeToTypePair() and ArrayType::addItemToArray()

Pass the active config explicitly - inside an extension that is $call->scope->config.

On UnionType and IntersectionType, the merge helpers also reordered their parameters, with Config now first:

// v1
$union->mergeDuplicateTypes($mergeAsIntersection, $config);

// v2
$union->mergeDuplicateTypes($config, $mergeAsIntersection);

MediaType and Parameter constructors

Both dropped the raw schema array parameter. Pass a Type and a Config instead - the schema is derived during JSON serialization:

// v1
new Parameter(name: 'id', in: 'path', schema: ['type' => 'integer']);

// v2
new Parameter(name: 'id', in: 'path', type: new IntegerType, config: $config);

ArgumentList replaces PhpFunctionArgument

The AutoDoc\Analyzer\PhpFunctionArgument class was removed. Use AutoDoc\Analyzer\ArgumentList:

  • ArgumentList::fromArgNodes($node->args, $scope) for parser call arguments
  • ArgumentList::fromTypes([$type], $scope) for synthetic arguments
  • $args->get($index), $args->has($index), count($args) and $args->findNamedIndex(...) to read them

Route::getRequestBodyType() takes a Scope

The argument changed from ?Config to Scope. Request bodies from multiple calls are now merged recursively; nested shapes with the same key are combined instead of becoming an anyOf.

Output changes

v2 resolves some types more precisely, so regenerated schemas and TypeScript files may differ from your v1 output even without code changes:

  • mixed is no longer implicitly nullable. In v1 a mixed value (a json_decode() result, for example) rendered as {"type": ["string", "null"]} in OpenAPI and unknown|null in TypeScript; it now renders without the redundant null. Declared nullables like ?Foo and explicit return null; branches keep rendering as nullable.
  • Native ?T, A|B and A&B parameter hints now seed the declared type. In v1 they resolved to unknown when the analyzer had no argument, PHPDoc tag or default value to work with.
  • In TypeScript unions, a null member is dropped when a sibling renders as unknown, since TS unknown already includes null. OpenAPI keeps the nullable union.
  • When declarations share a save_types_in_single_file target, nested classes and enums exported to that file are referenced by name instead of duplicated inline.

If you keep generated OpenAPI JSON or TypeScript files under version control, expect a one-time diff after regenerating with v2.