webmunkeez / cqrs-bundle
Command-Query-Responsibility-Segregation pattern made for Symfony.
Package info
github.com/yannissgarra/cqrs-bundle
Type:symfony-bundle
pkg:composer/webmunkeez/cqrs-bundle
Requires
- php: >=8.2
- doctrine/doctrine-bundle: ^2
- doctrine/orm: ^3
- phpdocumentor/reflection-docblock: ^6.0
- symfony/config: ^7.4
- symfony/dependency-injection: ^7.4
- symfony/event-dispatcher: ^7.4
- symfony/http-kernel: ^7.4
- symfony/property-access: ^7.4
- symfony/property-info: ^7.4
- symfony/serializer: ^7.4
- symfony/string: ^7.4
- symfony/translation: ^7.4
- symfony/uid: ^7.4
- symfony/validator: ^7.4
- twig/twig: ^3
Requires (Dev)
This package is auto-updated.
Last update: 2026-08-20 13:46:09 UTC
README
This bundle unleashes the Command-Query-Responsibility-Segregation pattern on Symfony applications.
Installation
Use Composer to install this bundle:
$ composer require webmunkeez/cqrs-bundle
Add the bundle in your application kernel:
// config/bundles.php return [ // ... Webmunkeez\CQRSBundle\WebmunkeezCQRSBundle::class => ['all' => true], // ... ];
Usage
Commands
A Command is a plain object describing an intent to change state. It must implement \Webmunkeez\CQRSBundle\Command\CommandInterface (or extend \Webmunkeez\CQRSBundle\Command\AbstractCommand):
final class PostCreateCommand extends \Webmunkeez\CQRSBundle\Command\AbstractCommand { private \Symfony\Component\Uid\Uuid $id; private string $title; public function getId(): \Symfony\Component\Uid\Uuid { return $this->id; } public function setId(\Symfony\Component\Uid\Uuid $id): self { $this->id = $id; return $this; } public function getTitle(): string { return $this->title; } public function setTitle(string $title): self { $this->title = $title; return $this; } }
A Command Handler implements \Webmunkeez\CQRSBundle\Command\CommandHandlerInterface (or extends \Webmunkeez\CQRSBundle\Command\AbstractCommandHandler, which gives you $this->validate(), $this->persist(), $this->remove(), $this->flush(), $this->clear(), $this->detach() and $this->refresh() — see Validator and Doctrine repositories):
final class PostCreateCommandHandler extends \Webmunkeez\CQRSBundle\Command\AbstractCommandHandler { public function handle(PostCreateCommand $command): void { $this->validate($command); $post = (new Post()) ->setId($command->getId()) ->setTitle($command->getTitle()); $this->validate($post); $this->persist($post); $this->flush(); } }
(Each service that implements CommandHandlerInterface is automatically tagged webmunkeez_cqrs.command_handler, and a compiler pass checks that its handle() method has exactly one parameter typed with a CommandInterface subclass.)
To resolve a Command directly as a controller argument, \Webmunkeez\CQRSBundle\Command\CommandValueResolver builds it from route parameters, query string and request body (JSON or application/x-www-form-urlencoded) merged together (route parameters win over the query string, which wins over the body, on a key collision).
Warning
Every public setter (and constructor-promoted property) on a Command/Query class is client-writable. The resolver denormalizes the full merged payload onto the target class with no allowlist — there is no Groups/field-level opt-in. Never put a server-only or computed field (an internal flag, an isAdmin-style property, anything meant to be assigned by your own code rather than the client) on a class resolved this way, or a client can set it just by adding a matching key to the request. If you need such a field, either compute/assign it in the handler instead of on the Command/Query, or don't use the value resolver for that field's write path.
A request whose body/query data doesn't match the declared property types (e.g. an array where a string was expected) is rejected with a 400 BadRequestHttpException rather than crashing — but a type-confused-yet-still-technically-valid value (e.g. a numeric string where an int was expected, which PHP happily coerces) still reaches your Command/Query, so don't skip validating it in your handler via Validator.
final class PostCreateAction { public function __construct( private readonly PostCreateCommandHandler $handler, ) { } public function __invoke(PostCreateCommand $command): Response { $this->handler->handle($command); return new Response(status: Response::HTTP_CREATED); } }
Queries
Queries and Query Handlers follow the exact same pattern, with \Webmunkeez\CQRSBundle\Query\QueryInterface/AbstractQuery and \Webmunkeez\CQRSBundle\Query\QueryHandlerInterface/AbstractQueryHandler (tagged webmunkeez_cqrs.query_handler), plus \Webmunkeez\CQRSBundle\Query\QueryValueResolver for controller argument resolution. The only difference is that AbstractQueryHandler doesn't give you Doctrine helpers (a query handler is expected to read, not write):
final class PostReadQueryHandler extends \Webmunkeez\CQRSBundle\Query\AbstractQueryHandler { public function __construct( private readonly PostReadRepository $repository, ) { } public function handle(PostReadQuery $query): Post { $this->validate($query); return $this->repository->findOne($query->getId()); } }
Validator
\Webmunkeez\CQRSBundle\Validator\ValidatorAwareInterface/ValidatorAwareTrait give any service $this->validate(mixed $value, array $groups = []), which runs the regular Symfony validator constraints and, on failure, throws \Webmunkeez\CQRSBundle\Exception\ValidationException with the list of violations (as \Webmunkeez\CQRSBundle\Validator\ConstraintViolation, exposing getPropertyPath()/getMessage() with property names already converted through your app's configured serializer name converter):
try { $this->validate($post, ['group1']); } catch (\Webmunkeez\CQRSBundle\Exception\ValidationException $e) { foreach ($e->getViolations() as $violation) { // $violation->getPropertyPath(), $violation->getMessage() } }
An uncaught ValidationException is automatically converted by ValidationExceptionListener into a \Webmunkeez\CQRSBundle\Exception\ValidationHttpException (a 422 response), serialized by ValidationHttpExceptionNormalizer as {"message": ..., "code": ..., "violations": [...]}.
Doctrine repositories
\Webmunkeez\CQRSBundle\Doctrine\ORM\Repository\AbstractDoctrineORMRepository is a thin ServiceEntityRepository for the regular Doctrine ORM use case (hydration is handled by Doctrine itself):
final class PostWriteRepository extends \Webmunkeez\CQRSBundle\Doctrine\ORM\Repository\AbstractDoctrineORMRepository { public function __construct(\Doctrine\Persistence\ManagerRegistry $registry) { parent::__construct($registry, Post::class); } }
\Webmunkeez\CQRSBundle\Doctrine\DBAL\Repository\AbstractDoctrineDBALRepository is for when you need to bypass the ORM and write raw DBAL queries (via $this->createQueryBuilder()/$this->getConnection()). Since DBAL gives you back plain associative arrays, it also implements NormalizerAwareInterface so you can rehydrate an object from the row with $this->denormalize() (see Serializer helpers) instead of mapping every column by hand:
final class PostReadRepository extends \Webmunkeez\CQRSBundle\Doctrine\DBAL\Repository\AbstractDoctrineDBALRepository { /** * @throws PostNotFoundException */ public function findOne(\Symfony\Component\Uid\Uuid $id): Post { $qb = $this->createQueryBuilder(); $qb->select('id', 'title')->from('cqrs_post', 'post'); $qb->where($qb->expr()->eq('id', ':id'))->setParameter('id', $id->toRfc4122()); $data = $qb->executeQuery()->fetchAssociative(); if (false === $data) { throw new PostNotFoundException(); } /** @var Post $post */ $post = $this->denormalize($data, Post::class); return $post; } }
\Webmunkeez\CQRSBundle\Doctrine\EntityManagerAwareInterface/EntityManagerAwareTrait give any service (command handlers already have it) $this->persist(), $this->remove(), $this->flush(), $this->clear(), $this->detach() and $this->refresh().
Serializer helpers
Two "Aware" pairs are available for any service, wired automatically by dedicated compiler passes (which only inject the default dependency if you haven't already configured your own setX() call on that service, so your own explicit wiring is never silently overridden):
\Webmunkeez\CQRSBundle\Serializer\Normalizer\NormalizerAwareInterface/NormalizerAwareTraitgive$this->normalize(mixed $object): array/$this->denormalize(mixed $data, string $type): mixed— object ↔ plain PHP array, no encoding format. Useful whenever you're already holding an array (like a DBAL row) and just need to hydrate/dehydrate an object.\Webmunkeez\CQRSBundle\Serializer\SerializerAwareInterface/SerializerAwareTraitgive$this->serialize(mixed $data, string $format, array $context = []): string/$this->deserialize(string $data, string $type, string $format, array $context = []): mixed— object ↔ encoded string (JSON, XML...).
Backed enums
\Webmunkeez\CQRSBundle\Model\BackedEnumInterface/BackedEnumTrait add translation and choice-list helpers to any native PHP backed enum:
enum PostStatusEnum: int implements \Webmunkeez\CQRSBundle\Model\BackedEnumInterface { use \Webmunkeez\CQRSBundle\Model\BackedEnumTrait; case DRAFT = 1; case PUBLISHED = 2; public static function getBaseTranslationKey(): string { return 'post_status'; } }
This gives you getName(), getValue(), getTranslationKey() (post_status.draft, translated through the optional getTranslationDomain()), getChoices(), getNameChoices(), tryFromName()/fromName(). \Webmunkeez\CQRSBundle\Serializer\Normalizer\BackedEnumNormalizer (de)normalizes any BackedEnumInterface to/from {"name": ..., "title": ..., "value": ...}, and the enum_cases/enum_trans_key Twig helpers (\Webmunkeez\CQRSBundle\Twig\EnumExtension) expose the same thing to templates.
If some cases group others (e.g. a status group made of several statuses), implement \Webmunkeez\CQRSBundle\Model\GroupBackedEnumInterface/GroupBackedEnumTrait instead and define the hierarchy:
public static function defineHierarchy(): array { return [ self::ACTIVE->value => [PostStatusEnum::DRAFT, PostStatusEnum::PUBLISHED], ]; }
->getChildren() and ->getChildrenValue() then return the grouped cases (or an empty array if the case isn't a group).
Paginator
\Webmunkeez\CQRSBundle\Model\Paginator is a generic paginated result:
/** @var Paginator<Post> $paginator */ $paginator = Paginator::init($posts, $postsTotal, $page, $limit); $paginator->getItems(); // array<Post> $paginator->getItemsTotal(); $paginator->getPage(); $paginator->getPagesTotal();
To denormalize a Paginator<T> property, add #[Context(['T' => T::class])] on it — \Webmunkeez\CQRSBundle\Serializer\Normalizer\PaginatorNormalizer uses it to denormalize each item to the right class:
final class PostListResult { /** @var Paginator<Post> */ #[Symfony\Component\Serializer\Attribute\Context(['T' => Post::class])] private Paginator $posts; }
Position
\Webmunkeez\CQRSBundle\Model\PositionInterface/PositionTrait help maintain an ordered/ranked list of objects. Implement getPosition()/setPosition()/updatePosition() yourself, the trait gives you:
initPosition(array $otherPositions): sets the position tomax(otherPositions) + 1(or1if the list is empty — e.g. the first item of a brand-new ordered list).resetPositions(array $positions): sorts the list and renumbers it sequentially from 1, only callingupdatePosition()on entries that actually changed.
Exceptions
\Webmunkeez\CQRSBundle\Exception\ValidationException→ converted to a 422ValidationHttpException(see Validator).\Webmunkeez\CQRSBundle\Exception\ModelNotFoundException(thrown by your own repositories, e.g. whenfindOne()doesn't find anything) → automatically converted to a 404NotFoundHttpExceptionbyModelNotFoundExceptionListener.
Warning
Both conversions forward the original exception's getMessage() verbatim into the client-facing HTTP exception — that's the point of throwing them (an HttpExceptionInterface's message is meant to be client-safe). Never embed an internal identifier, a table/column name, or unsanitized user input in a ValidationException/ModelNotFoundException message — whatever you put there goes straight into the response body.