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
- Agility: being able to react quickly to changing requirements, whether business, technical or organisational
- Scalability
- Testability, so TDD is possible
- Transparency
- A framework-agnostic model layer (DDD)
- One convention for directory structure and naming
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:
- Layers with clearly defined responsibilities
- CQRS, with Doctrine ORM for writes and Doctrine DBAL for reads
- The command bus pattern, using Symfony Messenger
- DDD
- As little magic as possible
- TDD
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.
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:
- Entrypoint: where the app starts (
index.php,bin/console). - Application: passes data to the domain and presents the result to the user. Controllers and console commands live here.
- Domain: your business logic.
- Use cases: command handlers that orchestrate the domain.
- Infrastructure: implementations for a specific technology.
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:
- Deptrac
- carlosas/phpat
- DaveLiddament/php-language-extensions
- Your own PHPStan extensions and custom rules
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.














































