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-laravelv2, which depends on421c/autodoc-phpv2.
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:
| v1 | v2 |
|---|---|
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>.jsonfiles, each holding one workspace config. They are merged under the inlineworkspacesarray, and inline entries win on key conflicts.- Each workspace can now override individual values from the global
uiblock, for example to use its own logo or wiki pages. - A wiki page entry can use an absolute
pathinstead of aurl. 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,nullfor dynamic calls),argTypes,node,scopeMethodCallContext-methodName,argTypes,getVarType()(lazy-resolved and cached),node,scopeStaticCallContext-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 nowScope::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 overrideType::unwrapType(),Type::deepResolve(),Type::removeNull()Type::isSubTypeOf($other, $config)ArrayType::convertShapeToTypePair()andArrayType::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 argumentsArgumentList::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:
mixedis no longer implicitly nullable. In v1 amixedvalue (ajson_decode()result, for example) rendered as{"type": ["string", "null"]}in OpenAPI andunknown|nullin TypeScript; it now renders without the redundant null. Declared nullables like?Fooand explicitreturn null;branches keep rendering as nullable.- Native
?T,A|BandA&Bparameter 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
nullmember is dropped when a sibling renders asunknown, since TSunknownalready includes null. OpenAPI keeps the nullable union. - When declarations share a
save_types_in_single_filetarget, 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.