When Regular CRUD Stops Working: Introduction to CQRS and Event Sourcing
\n\nHave you ever faced a situation where your PHP application grew, and grew... and one fine day a regular UserController turned into a 2000-line monster? Sound familiar? Bugs multiply, performance drops, and adding a new feature gives the whole team a nervous tic. Congratulations, you've reached the point where it's time to implement serious architectural patterns.
Today we'll talk about two powerful tools — CQRS (Command Query Responsibility Segregation) and Event Sourcing. Don't be scared by the complex terms. In reality, they are just elegant ways to say: "Let's stop mixing data reading and writing into one pile" and "Let's store not the current state, but the history of changes."
\n\nThis article is for PHP developers who have already mastered the basics of OOP and want to take their projects to the next level. You will learn how to apply CQRS and Event Sourcing in practice, see real code, and understand when these patterns are truly needed and when it's better to avoid them.
\n\nWhat is CQRS? Separating Commands and Queries
\n\nImagine your code is a restaurant. Usually, a waiter (controller) takes an order (write request), runs to the kitchen (database), prepares the dish (processes data), and immediately serves it (returns a response). Everything is fine while there are 10 people in the restaurant. But when 1000 guests arrive, the kitchen starts to choke.
\n\nCQRS (Command Query Responsibility Segregation) proposes to separate responsibilities: some chefs (commands) only cook, other waiters (queries) only serve. Technically, this means you use different models for reading and writing data.
\n\nBasic CQRS Implementation in PHP
\n\nLet's see how this looks in code. We'll start with a simple example without frameworks to understand the essence.
\n\n// Command — a DTO with data for changing state\nclass CreateUserCommand {\n public function __construct(\n public readonly string $email,\n public readonly string $name\n ) {}\n}\n\n// Command handler — business logic happens here\nclass CreateUserHandler {\n public function __construct(\n private UserRepository $repository,\n private EventDispatcher $dispatcher\n ) {}\n\n public function handle(CreateUserCommand $command): void {\n $user = new User($command->email, $command->name);\n $this->repository->save($user);\n $this->dispatcher->dispatch(new UserCreatedEvent($user->getId()));\n }\n}\n\n// Query — a DTO for retrieving data\nclass GetUserQuery {\n public function __construct(\n public readonly int $userId\n ) {}\n}\n\n// Query handler — read-only, no changes\nclass GetUserHandler {\n public function __construct(\n private UserReadRepository $readRepository\n ) {}\n\n public function handle(GetUserQuery $query): ?UserDTO {\n return $this->readRepository->findById($query->userId);\n }\n}\n\nNotice: we have two different repositories. UserRepository is responsible for writing (can use complex business logic, validation), while UserReadRepository is responsible for reading (can be a simple SQL query or even a denormalized table).
Command Bus
\n\nIn real projects, commands and queries are sent through a bus — a central dispatcher that finds the appropriate handler itself.
\n\n// Simple Command Bus implementation\nclass SimpleCommandBus {\n public function __construct(\n private array $handlers = []\n ) {}\n\n public function registerHandler(string $commandClass, object $handler): void {\n $this->handlers[$commandClass] = $handler;\n }\n\n public function dispatch(object $command): void {\n $handler = $this->handlers[$co