objects namespaces and application architecture
Event Sourcing and CQRS Orientation
Event sourcing stores the history of what happened instead of storing only the latest state. CQRS separates the code path that changes data from the code path that reads data.
They are often discussed together, but they are different ideas. You can use CQRS without event sourcing. You can use event sourcing without a fully separate read model. In real PHP jobs, you are more likely to maintain or integrate with these patterns than design them from scratch as a junior developer.
The most important judgement is knowing that these patterns solve specific problems and add real complexity. They are not a default architecture for ordinary CRUD screens.
Event Sourcing
In a typical application, a row stores the current state:
bank_accounts
id: 10
balance_pence: 2500
With event sourcing, the source of truth is the sequence of events:
AccountOpened(account_id: 10)
MoneyDeposited(account_id: 10, amount_pence: 3000)
MoneyWithdrawn(account_id: 10, amount_pence: 500)
The current balance is rebuilt by replaying those events.
<?php
declare(strict_types=1);
interface AccountEvent
{
}
final readonly class MoneyDeposited implements AccountEvent
{
public function __construct(
public int $amountPence,
) {
}
}
final readonly class MoneyWithdrawn implements AccountEvent
{
public function __construct(
public int $amountPence,
) {
}
}
final class BankAccount
{
private int $balancePence = 0;
public function apply(AccountEvent $event): void
{
if ($event instanceof MoneyDeposited) {
$this->balancePence += $event->amountPence;
}
if ($event instanceof MoneyWithdrawn) {
$this->balancePence -= $event->amountPence;
}
}
public function balancePence(): int
{
return $this->balancePence;
}
}
$events = [
new MoneyDeposited(3000),
new MoneyWithdrawn(500),
];
$account = new BankAccount();
foreach ($events as $event) {
$account->apply($event);
}
echo $account->balancePence() . PHP_EOL;
// Prints:
// 2500
The stored events explain how the account reached its current state. That audit trail is the main benefit.
Why Event Sourcing Is Useful
Event sourcing can help when history matters as much as current state. Examples include banking, accounting, inventory, booking, compliance-heavy workflows, and systems where business users ask "why did this value change?"
It can also let you rebuild new projections from old events. If you later need a report grouped by month, you can replay historical events into a new read model.
The cost is complexity. Events must be versioned, stored permanently, replayed reliably, and handled carefully when business rules change.
CQRS
CQRS means Command Query Responsibility Segregation. A command changes state. A query reads state. The idea is to separate those responsibilities when they have different needs.
In simple applications, one model can do both. In more complex systems, write logic may need strict business rules while read logic may need fast, denormalised views.
<?php
declare(strict_types=1);
final readonly class CompleteLessonCommand
{
public function __construct(
public int $userId,
public int $lessonId,
) {
}
}
final class CompleteLessonHandler
{
/** @var array<string, bool> */
private array $completed = [];
public function handle(CompleteLessonCommand $command): void
{
$key = $command->userId . ':' . $command->lessonId;
$this->completed[$key] = true;
}
/** @return array<string, bool> */
public function completedLessons(): array
{
return $this->completed;
}
}
final class ProgressQuery
{
/** @param array<string, bool> $completed */
public function __construct(
private array $completed,
) {
}
public function hasCompleted(int $userId, int $lessonId): bool
{
return $this->completed[$userId . ':' . $lessonId] ?? false;
}
}
$handler = new CompleteLessonHandler();
$handler->handle(new CompleteLessonCommand(10, 5));
$query = new ProgressQuery($handler->completedLessons());
echo $query->hasCompleted(10, 5) ? 'complete' : 'incomplete';
echo PHP_EOL;
// Prints:
// complete
This is a tiny example, but it shows the split: the command path changes state, and the query path answers a read question.
CQRS With Read Models
In larger systems, the read side may be a separate table, search index, cache, or projection. It is shaped for reading, not for enforcing write rules.
For example, a course platform may write completion events to an event store but maintain a course_progress_summary table for fast dashboard queries.
That read model may be eventually consistent. Immediately after completing a lesson, the write side may be correct while the dashboard projection is still catching up.
Event Sourcing Plus CQRS
Event sourcing and CQRS often appear together:
- commands validate intent and append events
- events are stored as the source of truth
- projectors read events and update query models
- queries read from those query models
That can be powerful, but it means more moving parts: event stores, projections, replay tools, versioning, snapshots, retries, duplicate handling, and operational monitoring.
When These Patterns Are Too Much
Event sourcing is usually too much when the application only needs ordinary create, update, delete screens. If the business does not care about the full history and the team does not have tooling for replay and event versioning, a normal relational model is often better.
CQRS is usually too much when reads and writes are simple and use the same shape of data. Separating every small feature into commands, handlers, queries, projectors, and read models can make the code harder to follow.
Use these patterns when the domain earns the complexity.
What You Should Be Able To Do
After this lesson, you should be able to define event sourcing as storing events as the source of truth, and CQRS as separating write and read paths. You should be able to read a small event replay example, understand why projections can be stale, and explain why these patterns should be introduced carefully.
For junior work, the practical skill is recognising the vocabulary in an existing codebase and not confusing events, commands, handlers, projections, and queries.
Practice
Practice: Replay Account Events
Create a small PHP example that rebuilds a bank account balance from events.
Task
Build:
- an
AccountEventmarker interface MoneyDepositedandMoneyWithdrawnevents- a
BankAccountclass that applies events - a small query method that returns the current balance
Replay at least three events and print the final balance.
Use strict types. Keep the expected output in the PHP code block as printed lines or comments.
Check Your Work
Confirm:
- the events describe facts that already happened
- the final state is rebuilt by replaying events
- the query method reads state but does not change it
Afterward, explain why this would be unnecessary for a simple CRUD screen.
Show solution
This solution stores the facts as events and rebuilds the balance by applying each event in order.
<?php
declare(strict_types=1);
interface AccountEvent
{
}
final readonly class MoneyDeposited implements AccountEvent
{
public function __construct(
public int $amountPence,
) {
if ($amountPence <= 0) {
throw new InvalidArgumentException('Deposit amount must be positive.');
}
}
}
final readonly class MoneyWithdrawn implements AccountEvent
{
public function __construct(
public int $amountPence,
) {
if ($amountPence <= 0) {
throw new InvalidArgumentException('Withdrawal amount must be positive.');
}
}
}
final class BankAccount
{
private int $balancePence = 0;
public function apply(AccountEvent $event): void
{
if ($event instanceof MoneyDeposited) {
$this->balancePence += $event->amountPence;
return;
}
if ($event instanceof MoneyWithdrawn) {
$this->balancePence -= $event->amountPence;
return;
}
}
public function balancePence(): int
{
return $this->balancePence;
}
}
$events = [
new MoneyDeposited(5000),
new MoneyWithdrawn(1250),
new MoneyDeposited(750),
];
$account = new BankAccount();
foreach ($events as $event) {
$account->apply($event);
}
echo $account->balancePence() . PHP_EOL;
// Prints:
// 4500
The query method is balancePence() because it reads the rebuilt state without changing it. This level of structure would usually be unnecessary for a simple CRUD screen because storing the current row state would be easier to understand and maintain.