CQRS and event sourcing get bundled together so often that people assume they're a package deal. They're not. You can separate your reads from your writes without ever storing a single event.

The Core Idea

CQRS — Command Query Responsibility Segregation — simply means using different models for reading and writing. That's it. No event store, no projections, no eventual consistency (unless you want it).

Commands and Handlers

A command represents an intention to change state. A handler executes it.

final readonly class PromoteMember
{
    public function __construct(
        public string $memberId,
        public string $newRole,
    ) {
    }
}

final readonly class PromoteMemberHandler
{
    public function __construct(private MemberRepository $members) {}

    public function __invoke(PromoteMember $command): void
    {
        $member = $this->members->ofId(new MemberId($command->memberId));
        $member->promoteTo(Role::from($command->newRole));
        $this->members->save($member);
    }
}

The write side uses rich domain objects. Entities, value objects, aggregates — whatever your domain needs.

Queries and Read Models

The read side doesn't need any of that. It needs to be fast and shaped for the UI.

final readonly class MemberListQuery
{
    public function __construct(
        public ?string $role = null,
        public int $page = 1,
    ) {
    }
}

final readonly class MemberListQueryHandler
{
    public function __construct(private Connection $connection) {}

    public function __invoke(MemberListQuery $query): MemberListResult
    {
        $qb = $this->connection->createQueryBuilder()
            ->select('id', 'name', 'email', 'role', 'joined_at')
            ->from('members')
            ->orderBy('joined_at', 'DESC');

        if ($query->role !== null) {
            $qb->andWhere('role = :role')
                ->setParameter('role', $query->role);
        }

        // Pagination, mapping to DTOs, etc.
        return MemberListResult::fromRows($qb->executeQuery()->fetchAllAssociative());
    }
}

Plain SQL, simple DTOs, no ORM hydration overhead. Your read model can be a completely different shape from your write model.

When This Pays Off

  • When your list pages are slow because Doctrine is hydrating entire aggregate graphs
  • When your API responses need a flat structure but your domain model is deeply nested
  • When different consumers need different views of the same data

When It Doesn't

If your app is a straightforward CRUD with no complex domain logic, CQRS is overhead. Use it when reads and writes have genuinely different needs.

The Takeaway

Start with the separation. Use Symfony Messenger to dispatch commands and queries through a bus. You can always add event sourcing later if the domain calls for it — but you probably won't need to.