Using CQRS in Symfony

Using CQRS in Symfony
Photo by Sigmund / Unsplash

Symfony is really great for building APIs very fast. Especially when using a CRUD / REST methodology. Simply because there are a lot of resources and tools build-in (API platform, request parameter converter, dependency injection into controller methods, ...).

There is not that much support for building apps with CQRS. On the projects I worked on the last years, we switched everything to CQRS (or even Event Sourcing). Not because it's better per se, but because it's better suited to handle more complex business logic.

Unfortunately there hasn't been a Symfony bundles to separate the relevant concerns from one another. So I built a CQRS bundle that I'm using in all projects and that finally reached version 0.1.0.

The bundle has to following goals:

  1. Make it very fast and easy to understand what is happening (from a business logic perspective).
  2. Make the code safer through extensive use of value objects.
  3. Make refactoring safer through the extensive use of types.
  4. Add clear boundaries between business logic and application / infrastructure logic.

How

The construct consists of two starting points, the CommandController and the QueryController and the following components:

Through the Symfony routing, we define which instances of the components (if relevant) are used for which route. We use PHP files for the routes instead of the default YAML for more type safety and so that renaming of components is easier through the IDE.

A route might look like this:

$routes->add(
    'api_news_create_news_article_command',
    '/api/news/create-news-article-command',
)
    ->controller([CommandController::class, 'handle'])
    ->methods([Request::METHOD_POST])
    ->defaults([
        'routePayload' => Configuration::routePayload(
            dtoClass: CreateNewsArticleCommand::class,
            handlerClass: CreateNewsArticleCommandHandler::class,
            dtoValidatorClasses: [
                UserIdValidator::class,
            ],
        ),
    ]);

You only need to define the components that differ from the defaults configured in the cqrs.yaml configuration. Read more about routing here.

Command example

Commands and queries are strongly typed value objects which already validate whatever they can. Here is an example command that is used to create a news article:

<?php

declare(strict_types=1);

namespace App\Domain\News\WriteSide\CreateNewsArticle;

use App\Helper\HtmlHelper;
use App\ValueObject\UserId;
use Assert\Assertion;
use DigitalCraftsman\CQRS\Command\Command;

final class CreateNewsArticleCommand implements Command
{
    public function __construct(
        public UserId $userId,
        public string $title,
        public string $content,
        public bool $isPublished,
    ) {
        Assertion::betweenLength($title, 1, 255);
        Assertion::minLength($content, 1);
        HtmlHelper::assertValidHtml($content);
    }
}

The structural validation is therefore already done through the creation of the command and the command handler only has to handle the business logic validation. A command handler might look like this:

<?php

declare(strict_types=1);

namespace App\Domain\News\WriteSide\CreateNewsArticle;

use App\DomainService\UserCollection;
use App\Entity\NewsArticle;
use App\Time\Clock\ClockInterface;
use App\ValueObject\NewsArticleId;
use DigitalCraftsman\CQRS\Command\Command;
use DigitalCraftsman\CQRS\Command\CommandHandlerInterface;
use Doctrine\ORM\EntityManagerInterface;

final class CreateNewsArticleCommandHandler implements CommandHandlerInterface
{
    public function __construct(
        private ClockInterface $clock,
        private UserCollection $userCollection,
        private EntityManagerInterface $entityManager,
    ) {
    }

    /** @param CreateProductNewsArticleCommand $command */
    public function handle(Command $command): void
    {
        $commandExecutedAt = $this->clock->now();

        // Validate
        $requestingUser = $this->userCollection->getOne($command->userId);
        $requestingUser->mustNotBeLocked();
        $requestingUser->mustHavePermissionToWriteArticle();

        // Apply
        $this->createNewsArticle($command, $commandExecutedAt);
    }

    private function createNewsArticle(
        CreateProductNewsArticleCommand $command,
        \DateTimeImmutable $commandExecutedAt,
    ): void {
        $newsArticleId = NewsArticleId::generateRandom();
        $newsArticle = new NewsArticle(
            $newsArticleId,
            $command->title,
            $command->content,
            $command->isPublished,
            $commandExecutedAt,
        );

        $this->entityManager->persist($newsArticle);
        $this->entityManager->flush();
    }
}

When you open look into the Github repository, you will realize that this blog post is more or less identical with the introduction README file of the repository. This is because I like README files that can be read easily. Please let me know whether that's the case or if there are still questions open that aren't answered by following the respective links in the repository to the specific docs.

I hope this bundle solves a need in the Symfony community 🙂