Symptom
The generated document has no root servers block, despite #[OA\Server] being declared. externalDocs is duplicated into every operation in the file instead of appearing once at the root.
Observed in the generated YAML before the fix:
/error-report:
post:
...
servers:
-
url: 'http://api.dotkernel.localhost'
description: 'Local development server'
grep '^servers:' public/openapi.yaml returned nothing; grep -c externalDocs returned 6 (one per operation) rather than 1.
Evidence
src/App/src/OpenAPI.php declares the document root as floating attributes:
#[OA\Info(version: '1.0', title: 'Dotkernel API')] // line 12
#[OA\Server(url: 'http://api.dotkernel.localhost', description: 'Local development server')] // line 13
#[OA\SecurityScheme(securityScheme: 'AuthToken', ...)] // line 14
#[OA\SecurityScheme(securityScheme: 'ErrorReportingToken', ...)] // line 15
#[OA\ExternalDocumentation(...)] // line 17
/**
* @see GetIndexResourceHandler::handle()
*/
#[OA\Get(path: '/', ...)]
/**
* @see PostErrorReportResourceHandler::handle()
*/
#[OA\Post(path: '/error-report', ...)]
// ... schemas ...
class OpenAPI // line 196
{
}
Root cause
Every attribute in the file attaches to the single class OpenAPI on line 196 — including the two operations. swagger-php then places each annotation by its declared allowed parents:
OpenApi\Annotations\Info::$_parents is [OpenApi::class] only, so Info has nowhere to go but the root. It works.
OpenApi\Annotations\Server::$_parents (Server.php:52) is [OpenApi::class, PathItem::class, Operation::class, Get::class, Post::class, Put::class, Delete::class, Patch::class, Head::class, Options::class, Trace::class].
ExternalDocumentation::$_parents (ExternalDocumentation.php:50) likewise includes the operation classes.
Because Server and ExternalDocumentation accept an operation as a parent, and operations are present in the same context, they nest into the operations rather than the document root. SecurityScheme survives because its only parents are OpenApi and Components.
This is not a swagger-php bug — the annotations are genuinely valid in both positions, and the attribute context is ambiguous. The declaration has to disambiguate.
Proposed fix
Nest the root metadata inside a single #[OA\OpenApi] attribute. OpenApi\Attributes\OpenApi is #[\Attribute(\Attribute::TARGET_CLASS)] and accepts info, servers, externalDocs and components, which leaves swagger-php no choice about placement:
#[OA\OpenApi(
info: new OA\Info(version: '1.0', title: 'Dotkernel API'),
servers: [
new OA\Server(url: 'http://api.dotkernel.localhost', description: 'Local development server'),
],
externalDocs: new OA\ExternalDocumentation(
description: 'Dotkernel API documentation',
url: 'https://docs.dotkernel.org/api-documentation/',
),
components: new OA\Components(
securitySchemes: [
new OA\SecurityScheme(
securityScheme: 'AuthToken',
type: 'http',
in: 'header',
bearerFormat: 'JWT',
scheme: 'bearer',
),
new OA\SecurityScheme(
securityScheme: 'ErrorReportingToken',
type: 'apiKey',
name: 'Error-Reporting-Token',
in: 'header',
),
],
),
)]
The two SecuritySchemes move inside OA\Components because they are no longer free-floating; they render in the same place as before.
An equally valid alternative is to move the five root attributes onto a dedicated class carrying no operations — then the root is again the only allowed parent. That keeps the attribute list flat at the cost of a second file. Both were tried in the fork and produce byte-identical output; the single-attribute version was kept to avoid the extra file.
Verification
After the fix the document gains its root block, and the duplicated externalDocs collapses from six copies to one:
openapi: 3.0.0
info:
title: 'Dotkernel API'
version: '1.0'
servers:
-
url: 'http://api.dotkernel.localhost'
description: 'Local development server'
Symptom
The generated document has no root
serversblock, despite#[OA\Server]being declared.externalDocsis duplicated into every operation in the file instead of appearing once at the root.Observed in the generated YAML before the fix:
grep '^servers:' public/openapi.yamlreturned nothing;grep -c externalDocsreturned 6 (one per operation) rather than 1.Evidence
src/App/src/OpenAPI.phpdeclares the document root as floating attributes:Root cause
Every attribute in the file attaches to the single
class OpenAPIon line 196 — including the two operations. swagger-php then places each annotation by its declared allowed parents:OpenApi\Annotations\Info::$_parentsis[OpenApi::class]only, soInfohas nowhere to go but the root. It works.OpenApi\Annotations\Server::$_parents(Server.php:52) is[OpenApi::class, PathItem::class, Operation::class, Get::class, Post::class, Put::class, Delete::class, Patch::class, Head::class, Options::class, Trace::class].ExternalDocumentation::$_parents(ExternalDocumentation.php:50) likewise includes the operation classes.Because
ServerandExternalDocumentationaccept an operation as a parent, and operations are present in the same context, they nest into the operations rather than the document root.SecuritySchemesurvives because its only parents areOpenApiandComponents.This is not a swagger-php bug — the annotations are genuinely valid in both positions, and the attribute context is ambiguous. The declaration has to disambiguate.
Proposed fix
Nest the root metadata inside a single
#[OA\OpenApi]attribute.OpenApi\Attributes\OpenApiis#[\Attribute(\Attribute::TARGET_CLASS)]and acceptsinfo,servers,externalDocsandcomponents, which leaves swagger-php no choice about placement:The two
SecuritySchemes move insideOA\Componentsbecause they are no longer free-floating; they render in the same place as before.An equally valid alternative is to move the five root attributes onto a dedicated class carrying no operations — then the root is again the only allowed parent. That keeps the attribute list flat at the cost of a second file. Both were tried in the fork and produce byte-identical output; the single-attribute version was kept to avoid the extra file.
Verification
After the fix the document gains its root block, and the duplicated
externalDocscollapses from six copies to one: