Symfony bundle that auto-generates OpenAPI 3.0.1 documentation for applications built on the Action-Domain-Responder (ADR) pattern. Works by scanning PHP source files for action classes annotated with #[Operation] and #[Route] attributes — no YAML configuration required.
- Zero-config documentation generation from PHP attributes
- Automatic schema inference from Symfony Form types (including validation constraints → OpenAPI constraints)
- Automatic schema inference from View classes (
ViewInterface,IterableView) - Automatic schema inference from plain DTO classes
- BackedEnum properties →
enumvalues in schemas Uuid/Ulid→{ type: string, format: uuid }DateTime/DateTimeImmutable→{ type: string, format: date-time }- GET/DELETE/HEAD requests: form fields automatically expanded as query parameters
- Recursive schema detection (cycle guard)
- Security via
#[IsGranted]— no extra annotation needed
- PHP 8.5+
- Symfony 8.x
chamber-orchestra/view-bundle
composer require chamber-orchestra/openapi-doc-bundleRegister the bundle in config/bundles.php:
return [
// ...
ChamberOrchestra\OpenApiDocBundle\OpenApiDocBundle::class => ['all' => true],
];Create a proto.yaml file in your project root. This file is merged into the final output and must contain securitySchemes for security annotations to appear in the generated documentation:
# proto.yaml
components:
securitySchemes:
BearerAuth:
type: http
scheme: bearer
bearerFormat: JWT
# Shared response references (used as string refs in #[Operation])
responses:
Unauthorized:
description: Unauthorized
NotFound:
description: Not found
Forbidden:
description: Forbidden
# Additional schemas not generated from code
schemas:
Error:
type: object
properties:
message:
type: stringNote: Without
securitySchemesin proto.yaml,#[IsGranted]annotations are silently ignored and operations appear as public in the generated documentation.
Each invokable action class needs #[Route] and #[Operation]:
use ChamberOrchestra\OpenApiDocBundle\Attribute\Operation;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
#[Route('/users/{id}', methods: ['GET'])]
#[IsGranted('ROLE_USER')]
#[Operation(
description: 'Get a user by ID',
responses: [UserView::class],
)]
class GetUserAction
{
public function __invoke(): UserView
{
// ...
}
}#[Route('/users', methods: ['POST'])]
#[IsGranted('ROLE_ADMIN')]
#[Operation(
description: 'Create a new user',
request: CreateUserForm::class,
responses: [UserView::class],
)]
class CreateUserAction
{
public function __invoke(): UserView { /* ... */ }
}The form fields are read at generation time and become the requestBody schema. Symfony validation constraints are mapped to OpenAPI constraints:
| Constraint | OpenAPI |
|---|---|
NotBlank |
required: [field] at schema level |
Length(min, max) |
minLength, maxLength |
Range(min, max) |
minimum, maximum |
Positive |
minimum: 1 |
GreaterThanOrEqual(n) |
minimum: n |
Count(min, max) |
minItems, maxItems (array fields) |
Email |
format: email |
Url |
format: uri |
ChoiceType(choices) |
enum values |
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\Extension\Core\Type\EmailType;
use Symfony\Component\Form\Extension\Core\Type\IntegerType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Validator\Constraints as Assert;
class CreateUserForm extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('name', TextType::class, [
'constraints' => [
new Assert\NotBlank(),
new Assert\Length(min: 2, max: 100),
],
])
->add('email', EmailType::class, [
'constraints' => [new Assert\NotBlank(), new Assert\Email()],
])
->add('age', IntegerType::class, [
'required' => false,
'constraints' => [new Assert\Range(min: 18, max: 120)],
])
->add('role', ChoiceType::class, [
'choices' => ['User' => 'user', 'Admin' => 'admin', 'Moderator' => 'moderator'],
'constraints' => [new Assert\NotBlank()],
]);
}
}This generates the following OpenAPI schema:
CreateUserForm:
type: object
required: [name, email, role]
properties:
name:
type: string
minLength: 2
maxLength: 100
email:
type: string
format: email
age:
type: integer
minimum: 18
maximum: 120
role:
type: string
enum: [user, admin, moderator]class AddressForm extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('street', TextType::class, ['constraints' => [new Assert\NotBlank()]])
->add('city', TextType::class, ['constraints' => [new Assert\NotBlank()]])
->add('zip', TextType::class);
}
}
class CreateOrderForm extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('title', TextType::class, ['constraints' => [new Assert\NotBlank()]])
->add('address', AddressForm::class); // nested form → $ref
}
}Generated schema:
CreateOrderForm:
type: object
required: [title]
properties:
title:
type: string
address:
$ref: '#/components/schemas/AddressForm'
AddressForm:
type: object
required: [street, city]
properties:
street:
type: string
city:
type: string
zip:
type: stringuse Symfony\Component\Form\Extension\Core\Type\CollectionType;
class CreateInvoiceForm extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('number', TextType::class, ['constraints' => [new Assert\NotBlank()]])
->add('lines', CollectionType::class, [
'entry_type' => InvoiceLineForm::class,
'constraints' => [new Assert\Count(min: 1)],
]);
}
}Generated schema:
CreateInvoiceForm:
type: object
required: [number]
properties:
number:
type: string
lines:
type: array
minItems: 1
items:
$ref: '#/components/schemas/InvoiceLineForm'GET/DELETE/HEAD actions automatically expand form fields into query parameters instead of a request body:
#[Route('/users', methods: ['GET'])]
#[IsGranted('ROLE_USER')]
#[Operation(
description: 'Search users',
request: SearchUsersForm::class,
responses: [UserListView::class],
)]
class SearchUsersAction
{
public function __invoke(): UserListView { /* ... */ }
}use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\Extension\Core\Type\IntegerType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
class SearchUsersForm extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('query', TextType::class, ['required' => false])
->add('role', ChoiceType::class, [
'required' => false,
'choices' => ['User' => 'user', 'Admin' => 'admin'],
])
->add('page', IntegerType::class, ['required' => false]);
}
}Each field becomes an individual query parameter:
parameters:
- name: query
in: query
schema:
type: string
- name: role
in: query
schema:
type: string
enum: [user, admin]
- name: page
in: query
schema:
type: integer#[Operation(
description: 'Update user',
request: UpdateUserForm::class,
responses: [
UserView::class, // Described as a component schema
'404' => 'NotFound', // String ref → #/components/responses/NotFound
'403' => 'Forbidden', // String ref → #/components/responses/Forbidden
],
)]Override security for a specific operation (e.g., API key instead of the default Bearer):
#[Operation(
description: 'Webhook endpoint',
security: ['ApiKeyAuth' => []],
)]Disable security for a public endpoint:
#[Operation(
description: 'Public endpoint',
security: [],
)]Use #[Property] to mark a property as required or add arbitrary OpenAPI attributes:
use ChamberOrchestra\OpenApiDocBundle\Attribute\Property;
class UserView
{
public Uuid $id;
public string $name;
#[Property(required: true, attr: ['example' => 'user@example.com'])]
public ?string $email = null; // nullable but explicitly required
#[Property(attr: ['minLength' => 3, 'maxLength' => 50])]
public string $username;
}Use #[Type] from chamber-orchestra/view-bundle to specify the item type:
use ChamberOrchestra\ViewBundle\Attribute\Type;
use ChamberOrchestra\ViewBundle\View\IterableView;
class UserListView extends IterableView
{
#[Type(UserView::class)]
protected array $entries = [];
}Generates:
UserListView:
type: array
items:
$ref: '#/components/schemas/UserView'php bin/console openapi-doc:generateOptions:
--src Source directory to scan (default: <project_dir>/src)
--output Output file path (default: <project_dir>/doc.yaml)
--proto Proto YAML file path (default: <project_dir>/proto.yaml)
--title API title (default: "API Documentation")
--doc-version API version string (default: "1.0.0")
Example:
php bin/console openapi-doc:generate \
--src src/Api \
--output public/openapi.yaml \
--proto proto.yaml \
--title "My API" \
--doc-version "2.1.0"| PHP type | OpenAPI |
|---|---|
string |
type: string |
int |
type: integer |
float |
type: number |
bool |
type: boolean |
array / iterable |
type: array |
BackedEnum |
type: string|integer + enum: [...] |
Uuid / Ulid |
type: string, format: uuid |
DateTime / DateTimeImmutable |
type: string, format: date-time |
| Custom class | $ref: '#/components/schemas/ClassName' |
| Symfony type | OpenAPI |
|---|---|
TextType |
type: string |
IntegerType |
type: integer |
NumberType |
type: number |
CheckboxType |
type: boolean |
EmailType |
type: string, format: email |
UrlType |
type: string, format: uri |
DateType |
type: string, format: date |
DateTimeType |
type: string, format: date-time |
ChoiceType |
type: string + enum: [...] |
ChoiceType(multiple: true) |
type: array, items: { enum: [...] } |
CollectionType |
type: array, items: { ... } |
RepeatedType |
type: object with sub-properties |
Custom FormTypeInterface |
$ref: '#/components/schemas/FormName' |
Action class
└─ Locator scans src/ for #[Operation] + #[Route]
└─ OperationDescriber
└─ SecurityParser #[IsGranted] → security placeholder
└─ RouteParser #[Route] → path / method / operationId
└─ OperationParser #[Operation] → description / request / responses
└─ ResponseParser __invoke return type → ComponentDescriber
└─ ComponentDescriber (lazy, per class)
└─ FormParser FormTypeInterface → schema from form fields + constraints
└─ ViewParser ViewInterface → schema from public properties
└─ ObjectParser plain DTO → schema from public properties
└─ DocumentBuilder
└─ merge proto.yaml
└─ resolve 'default' security → first securityScheme
└─ emit OpenAPI 3.0.1 YAML
composer test # all tests
composer test:unit # unit tests only
composer test:integration # integration tests onlyMIT