The OpenAPI Command Bundle is a Symfony bundle that allows you to build HTTP APIs around Command Bus messages (DTOs) without the need for manual controller creation or Symfony #[Route] attributes on your commands.
By using standard OpenAPI operation attributes (from zircote/swagger-php) directly on your command DTOs, this bundle automatically generates Symfony routes and handles the entire request-to-command lifecycle: deserialization, validation, dispatching to the messenger bus, and responding.
- OpenAPI-Driven Routing: Define your API endpoints directly on your command DTOs using
#[OA\Post],#[OA\Get],#[OA\Put], etc. - No Manual Controllers: A single
CommandControllerhandles all generated routes by default. - Automatic Deserialization: Automatically maps JSON request bodies, route parameters, and query parameters to your command DTOs.
- Two-Layer Validation: Each request is checked against the OpenAPI schema (headers, query parameters, body shape via
league/openapi-psr7-validator) and the deserialized command is validated with Symfony Validator constraints before it reaches your handler. - Messenger Integration: Dispatches your commands directly to the Symfony Messenger bus.
- Auto-Generated Documentation: Seamlessly integrates with
NelmioApiDocBundleto include your command-based routes in your OpenAPI/Swagger documentation. - Problem Details Support: Returns RFC 9457 problem details for validation and mapping errors.
composer require stixx/openapi-command-bundleIf you are using Symfony Flex, the bundle will be automatically enabled. Otherwise, add it to your config/bundles.php:
return [
// ...
Stixx\OpenApiCommandBundle\StixxOpenApiCommandBundle::class => ['all' => true],
];This bundle reads its routes from NelmioApiDocBundle's areas, so Nelmio must be registered and
configured — installing it via Composer is not enough. Its Flex recipe normally does this, but if
the recipe was skipped you have to do it by hand. Add it to config/bundles.php:
return [
// ...
Nelmio\ApiDocBundle\NelmioApiDocBundle::class => ['all' => true],
];And define at least one area in config/packages/nelmio_api_doc.yaml:
nelmio_api_doc:
areas:
default:
path_patterns: ['^/api']Without this, the container fails to compile and cache:clear reports which part
is missing — the config/bundles.php entry or the package config — along with
the configuration to add.
Every project keeps its command DTOs somewhere else, so the bundle scans only the directories you list in
config/packages/stixx_openapi_command.yaml. Glob patterns are allowed (* for one directory level, ** for any depth):
stixx_openapi_command:
command_paths:
- '%kernel.project_dir%/src/Command' # a single directory
# - '%kernel.project_dir%/src/*/Application/Command' # DDD: one directory per contextThe key is required: without it the container fails to compile. Set it to [] to turn discovery off and
import command routes from your routing config instead. See
Command Routing.
Annotate your command with OpenAPI attributes. No Symfony #[Route] is needed.
namespace App\Command;
use OpenApi\Attributes as OA;
use Symfony\Component\Validator\Constraints as Assert;
#[OA\Post(
path: '/api/projects',
operationId: 'create_project',
summary: 'Create a new project'
)]
final class CreateProjectCommand
{
public function __construct(
#[Assert\NotBlank]
#[Assert\Length(min: 3, max: 50)]
public string $name,
#[Assert\Length(max: 255)]
public ?string $description = null,
) {}
}Implement a standard Symfony Messenger handler for your command.
namespace App\Handler;
use App\Command\CreateProjectCommand;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
#[AsMessageHandler]
final class CreateProjectHandler
{
public function __invoke(CreateProjectCommand $command): array
{
// Your business logic here (e.g., persist to database)
return [
'id' => '123',
'name' => $command->name,
];
}
}The bundle automatically registers the route /api/projects (POST).
curl -X POST http://localhost:3000/api/projects \
-H "Content-Type: application/json" \
-d '{"name": "New Project", "description": "This is a project description"}'The bundle will:
- Detect the route and map it to
CreateProjectCommand. - Deserialize the JSON body into the command object.
- Validate the command using Symfony Validator.
- Dispatch the command to the Messenger bus.
- Return the handler's result as a JSON response with an appropriate status code (e.g.,
201 Created).
You can customize the bundle's behavior in config/packages/stixx_openapi_command.yaml:
stixx_openapi_command:
validation:
enabled: true
groups: ['Default']
cache_control: 'no-store' # Any valid Cache-Control directives, or null to disable
command_paths: # Required. Directories or glob patterns holding command DTOs; [] disables discovery
- '%kernel.project_dir%/src/Command'
openapi:
problem_details: true # Add the problem details response components to the generated OpenAPI documentation
warm_up: false # Generate the OpenAPI documents during cache:warmup instead of on the first API requestFor more detailed information, please refer to the following documentation:
- Changelog — release history and upgrade notes
- Command Routing & Request Handling
- Validation & Error Handling
- OpenAPI Integration
- Extension Points
The bundle distinguishes a small public API surface from its internal implementation. Public surface is the contract you can safely depend on; internals can change in any release.
Public (@api) — covered by the backward-compatibility policy:
| Type | What it is |
|---|---|
Stixx\OpenApiCommandBundle\StixxOpenApiCommandBundle |
Bundle entry point |
Stixx\OpenApiCommandBundle\Attribute\CommandObject |
Marks the controller argument a request is mapped to, or sets a command's controller |
Stixx\OpenApiCommandBundle\Exception\ApiProblemException |
Throw this from your code to express RFC 9457 problems |
Stixx\OpenApiCommandBundle\Exception\ExceptionToApiProblemTransformerInterface |
Replace or decorate to customize how exceptions become problem responses |
Stixx\OpenApiCommandBundle\Responder\ResponderInterface |
Implement to handle custom result shapes (CSV, XML, …) |
Stixx\OpenApiCommandBundle\Responder\StatusResolverInterface |
Replace or decorate to customize status code resolution |
Stixx\OpenApiCommandBundle\Responder\ResponseContext |
The request, command and handler result passed to responders and the status resolver |
Stixx\OpenApiCommandBundle\Validator\RequestValidatorInterface |
Implement to add custom request-level validation |
Stixx\OpenApiCommandBundle\Model\ProblemDetails |
The problem a response is written from, and its OpenAPI schema; extend it to add members |
Stixx\OpenApiCommandBundle\Model\ProblemDetailsInvalidRequestBody |
A problem with violations, and its OpenAPI schema |
Stixx\OpenApiCommandBundle\Model\Violation |
One violation of a rejected request or command, and its OpenAPI schema |
Internal (@internal) — everything else, including default implementations like JsonResponder, DefaultExceptionToApiProblemTransformer, RequestValidator, the controllers, DI extension, compiler passes, route loaders, and event subscribers. Do not extend or rely on these directly; replace them through the @api interfaces above.
The bundle's configuration schema (the keys under stixx_openapi_command:) is also part of the supported API.
See Extension Points for a worked example of each extension interface.
From 1.0.0 on, the bundle follows Semantic Versioning for its public API:
- the
@apitypes above, and the services registered underStatusResolverInterfaceandExceptionToApiProblemTransformerInterface, which you can alias or decorate; - the configuration keys under
stixx_openapi_command:; - the service tags named by
ResponderInterface::TAG_NAMEandRequestValidatorInterface::TAG_NAME, and the priorities of the built-in responders: -100, and -200 for the catch-all for objects and arrays; - the OpenAPI component names the bundle registers for your annotations when
openapi.problem_detailsis enabled (the default): the schemasProblemDetails,ViolationandProblemDetailsInvalidRequestBody, and the*ProblemDetailsResponseresponses; - the members of the problem responses, and the fields of a violation.
ProblemDetails and ProblemDetailsInvalidRequestBody may be extended, to add members to a problem.
The policy:
- Minor and patch releases don't break the public API.
- Removing or renaming part of it starts with a deprecation in a minor release. PHP code triggers
trigger_deprecation('stixx/openapi-command-bundle', '<version>', ...)and a configuration key is marked withsetDeprecated(), naming the replacement; the old name keeps working alongside the new one. The changelog lists the deprecation under Deprecated. - Deprecated code is removed in the next major release, listed under Removed with Upgrading notes. Run your
test suite with deprecations reported before upgrading a major: Symfony's PHPUnit bridge reports them by default;
with plain PHPUnit, set
ignoreSuppressionOfDeprecations="true"on the<source>element ofphpunit.xml, becausetrigger_deprecation()raises silenced deprecations. @internalcode can change in any release, without a deprecation.
Before 1.0.0, minor releases may break the public API; every such change comes with an Upgrading note in the changelog.
- PHP 8.4+
- Symfony 7.4+ or 8.0+
- NelmioApiDocBundle 5.8+, registered and configured with at least one area
This bundle is released under the MIT License.