Software Teams

CQRS Patterns in Practice: Separating Reads from Writes

How to implement CQRS (Command Query Responsibility Segregation) in PHP applications with practical patterns and real examples.

CQRS Without the Hype

Command Query Responsibility Segregation sounds complicated. The core idea is not: use different models for reading and writing data. Your write model enforces business rules and ensures data integrity. Your read model is optimized for querying and displaying data.

You are probably already doing a lightweight version of this. If you have ever created a database view, a denormalized table for reporting, or a search index alongside your primary database, you have applied the CQRS principle.

Why Separate Reads and Writes?

In most applications, reads vastly outnumber writes. A typical web application might have a 90/10 or 95/5 read-to-write ratio. Yet traditional architectures force both operations through the same model, creating compromises:

  • Your Eloquent model has 30 scopes and 15 relationships to serve various read patterns, making it enormous
  • Write validations and read queries compete for the same database connection pool
  • Optimizing the schema for writes (normalization, integrity constraints) conflicts with optimizing for reads (denormalization, fewer joins)
  • Complex reports require expensive joins that lock tables needed for writes

CQRS resolves these tensions by letting each side optimize independently.

CQRS Levels of Commitment

Level 1: Separate Query and Command Classes

The simplest form. No separate databases, no event sourcing. Just a clear separation in your application code.

Commands change state:

class FinalizeInvoiceCommand
{
    public function __construct(
        public readonly string $invoiceId,
        public readonly string $userId,
    ) {}
}

class FinalizeInvoiceHandler
{
    public function __construct(
        private readonly InvoiceRepository $invoices,
        private readonly InvoiceNumberGenerator $numbers,
    ) {}

    public function handle(FinalizeInvoiceCommand $command): void
    {
        $invoice = $this->invoices->findOrFail($command->invoiceId);

        $invoice->finalize(
            number: $this->numbers->next(),
            userId: $command->userId,
        );

        $this->invoices->save($invoice);
    }
}

Queries read state:

class GetInvoiceListQuery
{
    public function __construct(
        public readonly string $customerId,
        public readonly ?string $status = null,
        public readonly ?Carbon $fromDate = null,
        public readonly int $perPage = 25,
    ) {}
}

class GetInvoiceListHandler
{
    public function handle(GetInvoiceListQuery $query): LengthAwarePaginator
    {
        return DB::table('invoices')
            ->join('customers', 'invoices.customer_id', '=', 'customers.id')
            ->where('invoices.customer_id', $query->customerId)
            ->when($query->status, fn ($q) => $q->where('status', $query->status))
            ->when($query->fromDate, fn ($q) => $q->where('invoice_date', '>=', $query->fromDate))
            ->select([
                'invoices.id',
                'invoices.invoice_number',
                'invoices.total',
                'invoices.status',
                'invoices.invoice_date',
                'customers.name as customer_name',
            ])
            ->orderByDesc('invoice_date')
            ->paginate($query->perPage);
    }
}

Notice the query handler uses the query builder directly instead of Eloquent. It selects exactly the columns needed, joins efficiently, and returns paginated results. No model hydration overhead, no unnecessary relationship loading.

Level 2: Separate Read Models

Create dedicated database tables or views optimized for specific read patterns:

// Write side: normalized tables
// invoices, invoice_lines, customers, products

// Read side: denormalized table maintained by event listeners
Schema::create('invoice_list_view', function (Blueprint $table) {
    $table->uuid('id')->primary();
    $table->string('invoice_number');
    $table->string('customer_name');
    $table->string('customer_email');
    $table->integer('total_in_cents');
    $table->integer('line_count');
    $table->string('status');
    $table->date('invoice_date');
    $table->date('due_date');
    $table->boolean('is_overdue');
    $table->timestamps();

    $table->index(['status', 'invoice_date']);
    $table->index(['customer_name']);
    $table->fullText(['invoice_number', 'customer_name']);
});

Update the read model when write events occur:

class UpdateInvoiceListView
{
    public function handle(InvoiceFinalized $event): void
    {
        $invoice = Invoice::with(['customer', 'lines'])->find($event->invoiceId);

        InvoiceListView::updateOrCreate(
            ['id' => $invoice->id],
            [
                'invoice_number' => $invoice->number,
                'customer_name' => $invoice->customer->name,
                'customer_email' => $invoice->customer->email,
                'total_in_cents' => $invoice->total,
                'line_count' => $invoice->lines->count(),
                'status' => $invoice->status->value,
                'invoice_date' => $invoice->invoice_date,
                'due_date' => $invoice->due_date,
                'is_overdue' => $invoice->isOverdue(),
            ],
        );
    }
}

The read model table has zero joins, pre-computed values, and indexes tailored to the UI's filter and sort patterns. Queries against it are trivially fast.

Level 3: Separate Databases

For the highest scale and performance isolation, write to one database and read from another:

  • Write database: PostgreSQL with strict constraints, normalization, and ACID transactions
  • Read database: Elasticsearch for full-text search, Redis for real-time dashboards, or a second PostgreSQL optimized for analytical queries

This level introduces eventual consistency between the write and read sides. Events propagate asynchronously, so there is a brief window where a read might return stale data.

Handling Eventual Consistency

When the read model lags behind the write model:

Optimistic UI updates. After a successful write, update the UI optimistically on the client side. The read model catches up within milliseconds.

Read-your-own-writes. After a write, temporarily read from the write database for that specific user:

class InvoiceController
{
    public function store(CreateInvoiceRequest $request): JsonResponse
    {
        $command = CreateInvoiceCommand::fromRequest($request);
        $invoiceId = $this->bus->dispatch($command);

        // Read from write model immediately after creation
        $invoice = Invoice::findOrFail($invoiceId);

        return new JsonResponse(
            new InvoiceResource($invoice),
            201,
        );
    }
}

Polling with version checks. Include a version number in write responses. The client polls the read model until the version matches.

When CQRS Adds Unnecessary Complexity

  • Simple CRUD applications. If your reads and writes use the same data shape, CQRS adds ceremony without benefit.
  • Small teams. Maintaining two models requires discipline. A team of two might spend more time on infrastructure than on features.
  • Early-stage products. When the domain model changes weekly, maintaining synchronized read and write models doubles the refactoring cost.

Getting Started

Start with Level 1: separate your query and command handlers in code. This costs almost nothing, improves code organization, and makes it trivial to optimize reads and writes independently later. Only move to Level 2 or 3 when you have measured performance problems that simpler optimizations cannot solve.

Let's talk about your software teams needs

Whether you're modernizing your infrastructure, navigating compliance, or building new software - we can help.

Book a 30-min Call