· Péhápkaři · Pardubice · given in Czech

PHP app architecture: Symfony + DDD + CQRS, no event sourcing needed

PHP App architecture – Symfony + DDD + CQRS. The architecture I like to build Symfony apps with, shown on real code from Peon, my open-source project. The subtitle on the first slide said it plainly: Symfony + DDD + CQRS != Event Sourcing.

What software architecture is

A definition I like: “Software architecture is about making fundamental structural choices that are costly to change once implemented.” It’s like the architecture of buildings: a set of rules everyone should follow.

The Czech version of it is often “do it this way, because we’ve done it this way for a long time”. So the first rule of this talk: never copy anything 1:1, mine included, without knowing the answer to why.

What I expect from architecture

Why these? I work in a highly competitive startup environment. I want the “users” of the code, the programmers, to enjoy working with it. I don’t want to be ashamed of my work. We deploy continuously. And after years of practice, this simply feels right.

The common reality looks different: the app has no tests because it’s hard to test, it’s slow and hard to optimise, features take long and deadlines slip, upgrades hurt because of compatibility, and changing anything in the tech stack is extremely hard.

My favourite combination

Everything below comes from Peon, which is public on GitHub:

What CQRS is

Search for “CQRS PHP” and the top results are about CQRS and event sourcing. People often treat them as one thing. They aren’t.

CQRS means Command Query Responsibility Segregation. It’s a pattern that separates the operations that read data from the ones that change it. Instead of one model doing both, you have a command model that validates and writes, and a query model that reads and feeds the UI.

Martin Fowler’s CQRS diagram: the UI sends changes to a command model, which validates them and updates the database, while a separate query model reads from the database and serves presentations back to the UI through service interfaces.
Separate models for writing and reading. Diagram from Martin Fowler.

Nothing here says you have to store events. Both models can work on the same plain relational database.

A simple use case: user registration

To show the difference, I used the most boring example possible. Registering a user means: check the e-mail isn’t taken, save the user to the database, send a welcome e-mail.

The “get your hands dirty” way is to put all of it in the controller:

final class RegisterUserController extends AbstractController
{
    public function __construct(
        private readonly Database $database,
        private readonly Mailer $mailer,
    ) {}

    #[Route(path: '/register-user', name: 'register', methods: ['POST'])]
    public function __invoke(Request $request): Response
    {
        $email = $request->attributes->get('email');

        if ($this->database->find('users', ['email' => $email]) !== null) {
            return $this->render('...');
        }

        $plainTextPassword = $request->attributes->get('password');
        $hashedPassword = md5($plainTextPassword);

        $this->database->insert('users', [
            'email' => $email,
            'password' => $hashedPassword,
        ]);

        $this->mailer->send($email, new Message('...'));

        return $this->render('...');
    }
}

Then a new requirement arrives: we also need to register users by hand from the console. Now there’s a console command that copies the same validation, database queries and e-mail sending. Two entry points, two copies of the business logic.

So what is a controller actually responsible for? Taking information from the HTTP request, passing it to the domain, and returning a response (JSON, HTML, …). A console command does the same with its input and output. Neither should hold the logic itself. Both are just entry points into the domain.

With a command bus the controller shrinks to this:

final class RegisterUserController extends AbstractController
{
    public function __construct(
        private readonly CommandBus $commandBus,
    ) {}

    #[Route(path: '/register-user', name: 'register', methods: ['POST'])]
    public function __invoke(Request $request): Response
    {
        $email = $request->attributes->get('email');
        $plainTextPassword = $request->attributes->get('password');

        try {
            $this->commandBus->dispatch(
                new RegisterUser($email, $plainTextPassword),
            );
        } catch (UserWithEmailAlreadyRegistered) {
            $this->render('...');
        }

        return $this->render('...');
    }
}

The console command looks the same: read the arguments, dispatch RegisterUser, catch the exception, write a success or error message.

Building blocks and layers

A typical app is made of controllers and console commands, use cases and event subscribers (both Messenger handlers), entities as aggregate roots, commands and events as DTOs, value objects, query objects, Symfony forms, request and response DTOs, domain and validation services, exceptions and configuration files. Each of them belongs to one layer:

Layer diagram: entrypoints index.php and bin/console send requests and input to the application layer (controllers, console commands), which dispatches to message buses (command bus, event bus, with middleware) into the domain (use cases, aggregates, events, domain services). The domain dispatches events back to the buses. The application layer also reads through queries. An infrastructure layer spans underneath everything.
Writes go through the buses into the domain. Reads go straight from the application layer to queries.

In Peon the src directory follows that split: Cli, Domain, Infrastructure, Subscribers, Ui and UseCase.

Rules about layers only last if a machine checks them. Always try to enforce them in CI. Tools for that:

The write model

The aggregate root is a plain PHP class. No annotations, no framework:

class User
{
    public function __construct(
        public readonly UserId $userId,
        public readonly string $username,
        public string $hashedPassword,
    ) {}

    public function changePassword(string $hashedPassword): void
    {
        $this->hashedPassword = $hashedPassword;
    }
}

The command is a DTO, RegisterUser, with a username and a plain-text password. The use case is its handler:

final class RegisterUserHandler implements CommandHandlerInterface
{
    public function __construct(
        private readonly UsersCollection $usersCollection,
        private readonly HashPlainTextPassword $hashPlainTextPassword,
    ) {}

    public function __invoke(RegisterUser $command): void
    {
        $userId = $this->usersCollection->nextIdentity();
        $hashedPassword = $this->hashPlainTextPassword->hash($command->plainTextPassword);

        if ($this->usersCollection->usernameExists($command->username)) {
            throw new UserWithEmailAlreadyExists();
        }

        $user = new User($userId, $command->username, $hashedPassword);

        $this->usersCollection->save($user);
    }
}

Look at what the handler imports: only classes from Peon\Domain\User and the project’s own CommandHandlerInterface. Nothing from Symfony or Doctrine. The repository is an interface in the domain:

interface UsersCollection
{
    public function nextIdentity(): UserId;

    public function usernameExists(string $username): bool;

    /**
     * @throws UserNotFound
     */
    public function get(UserId $userId): User;

    public function save(User $user): void;
}

Doctrine lives in infrastructure. The entity is mapped in XML, so the domain class stays clean, and DoctrineUsersCollection implements the interface with the entity manager: persist and flush on save, a UUID v4 for the next identity, and find or a UserNotFound exception on get.

The read model

Reading is where CQRS pays off. The dashboard controller doesn’t touch entities at all. It asks small query interfaces for exactly what it needs:

$accessibleProjectIdentifiers = $this->getProjectIdentifiers->ownedByUser($userId);
$jobs = $this->provideReadJobs->provide($accessibleProjectIdentifiers, 10);
$projects = $this->provideReadProjects->provide($accessibleProjectIdentifiers);

Each query is an interface, for example ProvideReadJobs::provide(array $projectIdentifiers, int $maxJobsLimit): array, which returns an array of ReadJob objects. The Doctrine implementation uses plain SQL through DBAL. For jobs that’s a big query that joins jobs, projects, tasks and processes and sums up execution time. As one slide put it, this is where the heavy SQL goes. The rows are then hydrated straight into read objects:

$resultSet = $this->connection->executeQuery($sql, [
    'projectIdentifiers' => $projectIdentifiers,
    'maxJobs' => $maxJobsLimit,
], [
    'projectIdentifiers' => Connection::PARAM_STR_ARRAY,
    'maxJobs' => 'integer',
]);

return $this->hydrator->hydrateArrays(
    $resultSet->fetchAllAssociative(),
    ReadJob::class,
);

The read side can be optimised freely, one query per screen, without bending the write model around it. Access control is a query too: GetProjectIdentifiers::ownedByUser() returns the projects a user may see.

Testing it

Every piece has the same pattern: an interface, an implementation, a test. The controller test checks the page redirects to login when you aren’t signed in, and that a logged-in user sees two projects and five jobs from the data fixtures. The query test boots the kernel, gets ProvideReadJobs from the container and checks it against known fixture data:

$readJobs = $provideReadJobs->provide([
    new ProjectId(DataFixtures::USER_1_PROJECT_1_ID),
    new ProjectId(DataFixtures::USER_1_PROJECT_2_ID),
], 10);

self::assertCount(5, $readJobs);

Further reading

I closed with a book recommendation: Advanced Web Application Architecture by Matthias Noback. It covers this way of building apps in much more depth.

All 49 slides
  1. Slide 1: Title: Clean architecture, Symfony + DDD + CQRS != Event Sourcing, Jan Mikeš, #Pehapkari 08.09.2022
  2. Slide 2: What is software architecture? Costly-to-change structural choices, like rules in building architecture
  3. Slide 3: Never copy 1:1 without asking why; meme of a drawing copied from client to analyst to developer to code
  4. Slide 4: What I expect from architecture: agility, scalability, testability, transparency, framework-agnostic domain, one convention
  5. Slide 5: Why these requirements? Competitive startups, happy developers, continuous deployment, years of experience
  6. Slide 6: Expectation vs reality meme: graceful jump into a pool vs splashing face-first
  7. Slide 7: Common reality: no tests, slow app, late features, painful upgrades, hard tech stack changes
  8. Slide 8: My favourite combination (peon-dev/peon): clear layers, CQRS with Doctrine, Symfony Messenger command bus, DDD, TDD
  9. Slide 9: Section title slide: What is CQRS?
  10. Slide 10: Google search for "cqrs php" whose top result is about CQRS and Event Sourcing
  11. Slide 11: CQRS, Command Query Responsibility Segregation: separating reads from writes, explained in Czech
  12. Slide 12: Martin Fowler diagram: a single model reading from and updating the database for the UI
  13. Slide 13: Martin Fowler CQRS diagram: separate query model and command model between database and UI
  14. Slide 14: Close-up photo of dirty, muddy hands
  15. Slide 15: Simple use case, user registration: check duplicate email, save to database, send welcome email
  16. Slide 16: Diagram: framework passes request from index.php to a controller doing validation, DB queries, templating
  17. Slide 17: PHP code: RegisterUserController doing everything itself, database, password hashing, and mailer
  18. Slide 18: Same use case with a new requirement: register users manually from the console
  19. Slide 19: Diagram: framework with both a controller and a console command, each duplicating the logic
  20. Slide 20: PHP code: console command duplicating the registration logic from the controller
  21. Slide 21: Controller responsibilities: take data from the HTTP request, send it to the domain, return a response
  22. Slide 22: Console command responsibilities: take data from input, send it to the domain, output the result
  23. Slide 23: Diagram: entrypoints (index.php, bin/console) to application layer dispatching to a command bus with business logic
  24. Slide 24: PHP code: clean controller dispatching a RegisterUser command via the command bus
  25. Slide 25: PHP code: clean console command dispatching RegisterUser via the command bus
  26. Slide 26: Application building blocks: controllers, use cases, subscribers, entities, DTOs, value objects, queries, forms, and more
  27. Slide 27: Application layers: framework, entrypoints, domain, use cases, infrastructure, with src folder tree
  28. Slide 28: Layer diagram: entrypoints, application, message buses, domain with use cases, query, and infrastructure
  29. Slide 29: Clear layer responsibilities: entrypoint, application, domain holds business logic, infrastructure implements technology
  30. Slide 30: Tools and packages, always enforce in CI: Deptrac, phpat, php-language-extensions, custom PHPStan rules
  31. Slide 31: Section title slide: Write model
  32. Slide 32: PHP code: User aggregate root entity with changePassword method
  33. Slide 33: PHP code: RegisterUser command DTO with username and plain-text password
  34. Slide 34: PHP code: RegisterUserHandler hashing the password, checking duplicates, and saving the user
  35. Slide 35: PHP code: use statements showing the handler depends only on domain classes
  36. Slide 36: PHP code: UsersCollection repository interface with nextIdentity, usernameExists, get, save
  37. Slide 37: Doctrine XML mapping of the User entity to the users table
  38. Slide 38: PHP code: DoctrineUsersCollection implementing the repository with the entity manager
  39. Slide 39: Section title slide: Read model
  40. Slide 40: PHP code: DashboardController using read-model query services to render jobs and projects
  41. Slide 41: PHP code: GetProjectIdentifiers access-control interface returning projects owned by a user
  42. Slide 42: PHP code: ProvideReadJobs query interface returning ReadJob objects
  43. Slide 43: PHP code: Doctrine DBAL implementation of GetProjectIdentifiers with a raw SQL query
  44. Slide 44: PHP code: DoctrineProvideReadJobs with a long SQL query hydrated into ReadJob objects
  45. Slide 45: PHP code: DoctrineProvideReadJobs, condensed with the SQL placeholder
  46. Slide 46: PHP code: DashboardController functional test for login redirect and rendered projects and jobs
  47. Slide 47: PHP code: ProvideReadJobsTest kernel test asserting jobs returned from fixtures
  48. Slide 48: Book cover: Advanced Web Application Architecture by Matthias Noback
  49. Slide 49: Homer Simpson asking "Questions?" and Thank you! @honza_mikes, janmikes.cz

Related talks