digital-craftsman / cqrs
Build Symfony endpoints from typed commands, queries and handlers instead of fat controllers. Request decoding, DTO construction, validation, transactions and responses are handled by reusable components.
Package info
github.com/digital-craftsman-de/cqs-routing
Type:symfony-bundle
pkg:composer/digital-craftsman/cqrs
Requires
- php: 8.5.*|8.6.*
- symfony/framework-bundle: ^7.4|^8.0
- symfony/serializer: ^7.4|^8.0
Requires (Dev)
- digital-craftsman/ids: ^2.5
- digital-craftsman/self-aware-normalizers: ^1.4
- friendsofphp/php-cs-fixer: ^3.95
- phpunit/phpunit: ^13.4
- symfony/property-access: ^7.4|^8.0
- symfony/property-info: ^7.4|^8.0
- symfony/yaml: ^7.4|^8.0
- vimeo/psalm: ^6.20
Suggests
None
Provides
None
Conflicts
None
Replaces
None
- dev-main
- v2.2.0
- 2.1.0
- v2.0.1
- v2.0.0
- v1.1.0
- v1.0.1
- v1.0.0
- v0.13.2
- v0.13.1
- v0.13.0
- v0.12.0
- v0.11.0
- v0.10.0
- v0.9.0
- v0.8.1
- v0.8.0
- v0.7.0
- v0.6.0
- v0.5.0
- v0.4.0
- v0.3.0
- v0.2.0
- v0.1.0
- v0.1.0-beta.4
- v0.1.0-beta.3
- v0.1.0-beta.2
- v0.1.0-beta.1
- v0.1.0-alpha.11
- v0.1.0-alpha.10
- v0.1.0-alpha.9
- v0.1.0-alpha8
- v0.1.0-alpha7
- v0.1.0-alpha.6
- v0.1.0-alpha.5
- v0.1.0-alpha.4
- 0.1.0-alpha3
- 0.1.0-alpha.2
- 0.1.0-alpha.1
- dev-php-8.6
This package is auto-updated.
Last update: 2026-10-31 14:53:29 UTC
README
Build Symfony endpoints from small, typed building blocks instead of fat controllers. Each endpoint is just a command or query, a handler with the business logic and one route definition. Everything around it – decoding the request, constructing the DTO, validation, transactions and building the response – is done by reusable components that you configure once.
Why?
Symfony makes it very easy to build CRUD and REST endpoints. But the controller quickly becomes the place where everything happens: parsing the request, validating input, checking permissions, starting transactions, executing the business logic and building the response. It's fast to write, but expensive to change. In short, it's a low cost of introduction at the expense of the cost of change.
This bundle applies CQS (command query separation) to the HTTP layer and drastically reduces the cost of change with only slightly higher costs of introduction:
- Business logic only in handlers. Handlers never see the request. They receive a typed command or query and only contain domain logic.
- No duplicated infrastructure code. Request decoding, DTO construction, transactions, locking and response construction are written once as components and reused for every route.
- Independent endpoints. Every endpoint has its own command or query. Changing one endpoint can't accidentally break another one.
- Type safe from request to response. Commands and queries are value objects that validate themselves on construction. Static analysis tools like Psalm and PHPStan understand every step.
- Safe refactoring. Routes are defined in PHP with class references instead of strings in YAML, so renaming a class in your IDE updates the routing as well.
- Fails early. All routes are validated on cache warmup, so a misconfigured route is found before a request ever hits it – with no validation costs at runtime.
Before and after
A classic controller mixes infrastructure and business logic:
#[Route('/api/news/create-news-article', methods: ['POST'])] public function createNewsArticle(Request $request): JsonResponse { $data = json_decode($request->getContent(), true); if (!isset($data['title'], $data['content'])) { return new JsonResponse(['error' => 'Missing data'], 400); } $user = $this->userRepository->getOne($this->getUser()->getId()); $user->mustHavePermissionToWriteArticle(); $this->connection->beginTransaction(); try { $this->entityManager->persist(new NewsArticle(/* ... */)); $this->entityManager->flush(); $this->connection->commit(); } catch (\Throwable $exception) { $this->connection->rollBack(); throw $exception; } return new JsonResponse(null, 204); }
With CQS routing, the same endpoint is a route definition, a command and a handler. Decoding, DTO construction, the transaction and the response are handled by components that were configured once for all routes:
RouteBuilder::addCommandRoute( $routes, path: '/api/news/create-news-article-command', dtoClass: CreateNewsArticleCommand::class, handlerClass: CreateNewsArticleCommandHandler::class, );
final readonly class CreateNewsArticleCommandHandler implements CommandHandler { public function __invoke(CreateNewsArticleCommand $command): void { $user = $this->userRepository->getOne($command->userId); $user->mustHavePermissionToWriteArticle(); $this->entityManager->persist(new NewsArticle(/* ... */)); $this->entityManager->flush(); } }
How it works
Every request runs through the same pipeline. The CommandController and the QueryController take care of it, you only define which components to use.
graph LR;
Request[/Request/] --> Decode(Request decoder)
Decode --> Construct(DTO constructor)
Construct --> DTO[/"Command / Query"/]
DTO --> Handle(Handler)
Handle --> Respond(Response constructor)
Respond --> Response[/Response/]
Loading
These are the components of the pipeline. Only the request decoder, the DTO constructor and the response constructor are required, and the bundle already ships with implementations for them.
| Component | Purpose | Included implementations |
|---|---|---|
| Request validator | Validates the request on an application level (e.g. headers, virus scan). | – |
| Request decoder | Turns the request into request data as an array. | JsonRequestDecoder |
| Request data transformer | Casts, sanitizes or adds request data. | – |
| DTO constructor | Constructs the command or query from the request data. | SerializerDTOConstructor |
| DTO validator | Validates the command or query against application or infrastructure state. | – |
| Handler | Contains the business logic. Only query handlers return a value. | – |
| Handler wrapper | Runs logic before, after and on failure of a handler (e.g. transactions, locks). | SilentExceptionWrapper |
| Response constructor | Turns the return value of the handler into a response. | EmptyResponseConstructor, EmptyJsonResponseConstructor, SerializerJsonResponseConstructor, StreamedResponseConstructor |
The full process including all optional components and when to use (and not to use) which component is described here.
Installation
Requires PHP 8.5 or 8.6 and Symfony 7.4 or 8.
composer require digital-craftsman/cqs-routing
Configuration
Add a config/packages/cqs-routing.php file with the default components for all commands and queries:
<?php declare(strict_types=1); use DigitalCraftsman\CQSRouting\DTOConstructor\SerializerDTOConstructor; use DigitalCraftsman\CQSRouting\RequestDecoder\JsonRequestDecoder; use DigitalCraftsman\CQSRouting\ResponseConstructor\EmptyJsonResponseConstructor; use DigitalCraftsman\CQSRouting\ResponseConstructor\SerializerJsonResponseConstructor; use Symfony\Component\DependencyInjection\Loader\Configurator\App; return App::config([ 'cqs_routing' => [ 'query' => [ 'default_request_decoder_class' => JsonRequestDecoder::class, 'default_dto_constructor_class' => SerializerDTOConstructor::class, 'default_response_constructor_class' => SerializerJsonResponseConstructor::class, ], 'command' => [ 'default_request_decoder_class' => JsonRequestDecoder::class, 'default_dto_constructor_class' => SerializerDTOConstructor::class, 'default_response_constructor_class' => EmptyJsonResponseConstructor::class, ], ], ]);
With this, you're ready to go. The full configuration (including an example in YAML) shows how to add default request validators, request data transformers, DTO validators and handler wrappers.
All components are registered automatically through autoconfiguration as soon as they implement the relevant interface.
Usage
An endpoint consists of three parts: a command (or query), a handler and a route.
1. Create a command
Commands and queries are strongly typed value objects which validate whatever they can on construction. When the object exists, it's valid.
<?php declare(strict_types=1); namespace App\Domain\News\WriteSide\CreateNewsArticle; use App\Helper\HtmlHelper; use App\ValueObject\UserId; use Assert\Assertion; use DigitalCraftsman\CQSRouting\Command\Command; final readonly class CreateNewsArticleCommand implements Command { public function __construct( public UserId $userId, public string $title, public string $content, public bool $isPublished, ) { Assertion::notBlank($this->title); Assertion::maxLength($this->title, 255); Assertion::notBlank($this->content); Assertion::maxLength($this->content, 1000); HtmlHelper::assertValidHtml($this->content); } }
2. Create a handler
The structural validation is already done by the command, so the handler only contains the business logic. It's invoked with the command and never touches the request.
<?php declare(strict_types=1); namespace App\Domain\News\WriteSide\CreateNewsArticle; use App\Entity\NewsArticle; use App\Repository\UserRepository; use App\ValueObject\NewsArticleId; use DigitalCraftsman\CQSRouting\Command\CommandHandler; use Doctrine\ORM\EntityManagerInterface; use Psr\Clock\ClockInterface; final readonly class CreateNewsArticleCommandHandler implements CommandHandler { public function __construct( private ClockInterface $clock, private UserRepository $userRepository, private EntityManagerInterface $entityManager, ) { } public function __invoke(CreateNewsArticleCommand $command): void { $commandExecutedAt = $this->clock->now(); // Validate $requestingUser = $this->userRepository->getOne($command->userId); $requestingUser->mustNotBeLocked(); $requestingUser->mustHavePermissionToWriteArticle(); // Apply $newsArticle = new NewsArticle( NewsArticleId::generateRandom(), $command->title, $command->content, $command->isPublished, $commandExecutedAt, ); $this->entityManager->persist($newsArticle); $this->entityManager->flush(); } }
Query handlers work the same way, but return a value (usually a read model), which is then turned into a response by the configured response constructor. See the handler examples.
3. Add the route
Routes are defined in PHP through the RouteBuilder. You only need to define the components that differ from the defaults in your cqs-routing.php.
<?php declare(strict_types=1); use DigitalCraftsman\CQSRouting\Routing\RouteBuilder; use Symfony\Component\Routing\Loader\Configurator\RoutingConfigurator; return static function (RoutingConfigurator $routes) { RouteBuilder::addCommandRoute( $routes, path: '/api/news/create-news-article-command', dtoClass: CreateNewsArticleCommand::class, handlerClass: CreateNewsArticleCommandHandler::class, dtoValidatorClasses: [ UserIdValidator::class => null, ], ); RouteBuilder::addQueryRoute( $routes, path: '/api/news/get-news-articles-query', dtoClass: GetNewsArticlesQuery::class, handlerClass: GetNewsArticlesQueryHandler::class, ); };
The RouteBuilder chooses the controller (addCommandRoute or addQueryRoute), uses POST as default method, generates the route name from the path and validates all parameters on cache warmup. Read more about routing, including how to extend or overwrite the defaults per route.
Further documentation
- Process and component usage – what each component is for and what it must not be used for.
- Routing – all route options and how defaults are merged or overwritten.
- Configuration – the full bundle configuration.
- Examples: request validator, request decoder, request data transformer, DTO constructor, DTO validator, handler, handler wrapper, response constructor.
- Changelog and upgrade guide.
