How to implement CQRS (Command Query Responsibility Segregation) in PHP applications with practical patterns and real examples.
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.
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:
CQRS resolves these tensions by letting each side optimize independently.
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.
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.
For the highest scale and performance isolation, write to one database and read from another:
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.
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.
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.
Whether you're modernizing your infrastructure, navigating compliance, or building new software - we can help.
Book a 30-min Call