Index

CQRS Starts When Reads Need a Different Model

An order-details page needs customer name, payment status, shipment progress, line totals, tax labels, and the latest support case. The write model needs to protect payment, cancellation, and fulfilment invariants.

Trying to make one Eloquent graph serve both jobs creates a familiar mess: relationships exist for screen composition, write methods expose persistence concerns, and every request loads a different subset of a model that is never quite complete.

That tension can justify different read and write models. It does not automatically justify two databases, an event store, a command bus, or eventual consistency. CQRS is the model split; the infrastructure is a separate set of decisions.

Command-query separation is the smaller rule

Command-Query Separation says a function should either change state or return a result, not make callers guess which happened:

final class Order
{
    public function cancel(CancellationReason $reason): void
    {
        // Validate and change state.
    }

    public function canBeCancelled(): bool
    {
        // Inspect state without changing it.
    }
}

The rule improves local reasoning. A caller can ask canBeCancelled() without triggering a cancellation, and cancel() names its side effect.

Practical APIs sometimes return useful information from a mutation:

$result = $payments->capture($request);

The capture cannot avoid producing a gateway transaction ID. Splitting it into capture() followed by lastTransactionId() would make concurrency and failure behavior worse. The useful principle is explicit side effects, not mechanical void return types.

Creating a CancelOrderCommand class and a FindOrderQuery class is still only code organization. CQRS begins when the system uses different models for changing and reading information. Martin Fowler’s CQRS description makes that distinction and warns that the pattern adds risky complexity to most systems.

Split the code before splitting storage

The first useful step can use one transactional database. Let the command side load the aggregate it needs:

final readonly class CancelOrderHandler
{
    public function __construct(private Orders $orders) {}

    public function handle(CancelOrder $command): void
    {
        DB::transaction(function () use ($command): void {
            $order = $this->orders->getForUpdate($command->orderId);
            $order->cancel($command->reason);
            $this->orders->save($order);
        });
    }
}

Let the read side select the shape the page needs without hydrating that aggregate:

final readonly class FindOrderDetails
{
    public function byId(OrderId $id): ?OrderDetails
    {
        $row = DB::table('orders')
            ->join('customers', 'customers.id', '=', 'orders.customer_id')
            ->leftJoin('shipments', 'shipments.order_id', '=', 'orders.id')
            ->where('orders.id', $id->value)
            ->select([
                'orders.id',
                'orders.status',
                'customers.name as customer_name',
                'shipments.status as shipment_status',
            ])
            ->first();

        return $row === null ? null : OrderDetails::fromRow($row);
    }
}

The models differ even though the tables do not. The command model expresses valid transitions. The query model expresses one consumer’s projection. This often captures most of CQRS’s design value without synchronization machinery.

The query must not become a back door for writes. Returning a purpose-built OrderDetails DTO rather than an active Eloquent model makes that boundary visible.

Read replicas solve load distribution, not model mismatch

Laravel supports separate read and write database connections. That can distribute database traffic while both paths still use the same tables and model.

It also introduces replication lag. Laravel’s sticky option sends reads to the write connection after a write during the same request, which can provide read-your-write behavior for that request. It does not guarantee that a later request reaches a caught-up replica.

Read/write connections are therefore not CQRS by themselves. They change where a query executes. CQRS changes the model used to answer it. Either choice can exist without the other.

A projection earns its own storage through evidence

The order-details query may eventually become too expensive or too dependent on data owned by other services. Before adding a projection, collect the query plan, latency distribution, call volume, update frequency, and freshness requirement.

A dedicated read table might look like this:

create table order_details (
    order_id uuid primary key,
    customer_name varchar(255) not null,
    order_status varchar(40) not null,
    shipment_status varchar(40),
    total_minor bigint not null,
    currency char(3) not null,
    projected_at timestamp not null
);

Events or a change-data-capture stream can update it. Microsoft’s CQRS pattern guidance describes the same trade: a query-optimized store removes joins from the read path, but it can lag behind the write model. Now the page reads one row, but the system has accepted a consistency gap:

OrderCancelled committed
        ├── API acknowledges cancellation
        └── projection message delayed
                 └── order-details still says "placed"

The UI and API contract must decide how to represent that interval. Options include returning the command result directly, showing a pending state, polling until a projection version advances, or keeping strongly consistent reads for workflows that cannot tolerate staleness.

“Eventually consistent” is incomplete without a target delay, an observable lag signal, and behavior for a projector that stops.

Event sourcing is optional and changes the write model

CQRS does not require event sourcing. A command model can persist ordinary rows while a separate query model reads joins or projections.

Event sourcing stores the write model’s state transitions as events and rebuilds current state from them. That can support audit and temporal questions, but it adds event compatibility, replay, snapshot, and correction problems. Microsoft’s event-sourcing guidance documents those obligations, including idempotent handlers, event ordering, schema evolution, and projection rebuilds. Combining it with CQRS is common because events can feed read projections; it is not part of the definition.

Adopting both at once makes it difficult to tell which complexity bought which benefit. Split models first if that is the observed problem. Introduce event sourcing only when event history itself is a required source of truth.

Failure behavior belongs in the design

A production projection needs more than a listener. The failure modes in the event-sourcing guidance translate into concrete operating requirements:

  • an event or source position for idempotency;
  • ordered handling where order matters;
  • retries and a visible dead-letter path;
  • rebuild tooling that does not overload the write store;
  • a schema and event-version migration policy; and
  • lag metrics tied to consumer freshness requirements.

Test the gap deliberately:

it('does not expose cancelled state before projection', function () {
    $order = anOrder(status: OrderStatus::Placed);
    $projection = OrderDetailsProjection::from($order);

    $order->cancel(CustomerRequest::received());

    expect($projection->status)->toBe('placed');

    $projection->apply(OrderCancelled::from($order));

    expect($projection->status)->toBe('cancelled');
});

That test documents reality rather than pretending every read changes at transaction commit.

Stop at the cheapest separation that solves the problem

Use command-query separation freely when it makes side effects explicit. Use separate command and query classes when use cases need different dependencies or response shapes. Use distinct read and write models when their structures and rates of change genuinely conflict.

Move the read model to separate storage only when measured query pressure, cross-system composition, availability, or independent scaling justifies the consistency and operational cost.

CQRS is not proven by folder names. It is justified when two models explain the system better than one—and when the team can operate the delay between them.