Back to blog
event sourcingsoftware architectureknowledge base

Command Query Responsibility Segregation: A Practical Guide to CQRS

December 12, 2023
Command Query Responsibility Segregation: A Practical Guide to CQRS

Most applications use the same model for reading and writing data. A single model handles both displaying a customer's order history and processing a new order. This works fine at small scale. In complex domains or under real load, it becomes a source of friction: slow queries, tangled logic, and models that try to be everything to everyone.

Command Query Responsibility Segregation (CQRS) solves this by splitting those two concerns into separate models. Commands change state. Queries read state. Each side can be designed and optimized independently.

This guide covers the CQRS pattern in depth: what it is, how to implement it in Laravel with PHP, when it makes sense, and when it does not.

What is CQRS?

CQRS is an architectural pattern that separates the write model (commands) from the read model (queries). The concept was popularized by Greg Young, building on Bertrand Meyer's Command-Query Separation (CQS) principle, which states that a method should either change state or return data, but not both.

CQRS applies that principle at the architecture level:

  • Commands express intent to change state. PlaceOrder, CancelSubscription, UpdateUserEmail. They do not return data beyond an ID or acknowledgment.
  • Queries retrieve data for display. They never modify state.

When you use the same model for reads and writes, you make trade-offs in both directions. Your read model gets polluted with write concerns. Your write model accumulates view-specific logic. Your queries load full domain objects when you only need a name and a status. CQRS lets you stop making those compromises.

The problem CQRS solves

Consider a financial application where users see their account balance and recent transactions. The write model stores individual transactions for integrity and auditability. The read model needs a pre-calculated balance and a denormalized list of transactions with payee names resolved.

Without CQRS, you are either calculating the balance on every read (slow) or maintaining it alongside transactions (complex, prone to inconsistency). With CQRS, the write model stores transactions, and a separate read model maintains the balance and resolved transaction view. Each side is optimized for its purpose.

This is the core insight: read and write operations have fundamentally different performance and structural requirements. Treating them as the same thing creates avoidable complexity.

CQRS architecture: the core components

A CQRS system has four primary components.

Commands are value objects expressing intent:

final class PlaceOrder
{
    public function __construct(
        public readonly string $customerId,
        public readonly array $items,
        public readonly string $shippingAddress,
    ) {}
}

Command handlers execute the business logic:

final class PlaceOrderHandler
{
    public function __construct(
        private readonly OrderRepository $orders,
        private readonly EventDispatcher $events,
    ) {}

    public function handle(PlaceOrder $command): string
    {
        $order = Order::place(
            customerId: $command->customerId,
            items: $command->items,
            shippingAddress: $command->shippingAddress,
        );

        $this->orders->save($order);
        $this->events->dispatch(new OrderPlaced($order->id));

        return $order->id;
    }
}

Queries request specific data:

final class GetOrderSummary
{
    public function __construct(
        public readonly string $orderId,
    ) {}
}

Query handlers fetch and return read-optimized data transfer objects:

final class OrderSummaryDto
{
    public function __construct(
        public readonly string $id,
        public readonly string $status,
        public readonly string $customerName,
        public readonly float $total,
        public readonly int $itemCount,
        public readonly Carbon $placedAt,
    ) {}
}

final class GetOrderSummaryHandler
{
    public function handle(GetOrderSummary $query): OrderSummaryDto
    {
        $row = DB::table('orders')
            ->join('customers', 'orders.customer_id', '=', 'customers.id')
            ->where('orders.id', $query->orderId)
            ->select([
                'orders.id',
                'orders.status',
                'orders.total',
                'orders.item_count',
                'orders.placed_at',
                'customers.name as customer_name',
            ])
            ->firstOrFail();

        return new OrderSummaryDto(
            id: $row->id,
            status: $row->status,
            customerName: $row->customer_name,
            total: $row->total,
            itemCount: $row->item_count,
            placedAt: Carbon::parse($row->placed_at),
        );
    }
}

Notice the query handler does not use Eloquent models or domain logic. It queries directly and returns exactly what the view needs. No loading a full Order with all its relations just to display a status badge.

Implementing a command bus in Laravel

A command bus routes commands to their handlers. Laravel does not include a dedicated CQRS command bus, but building one is straightforward using the service container:

final class CommandBus
{
    public function __construct(
        private readonly Container $container,
    ) {}

    public function dispatch(object $command): mixed
    {
        $commandClass = get_class($command);
        $handlerClass = $commandClass . 'Handler';

        if (!class_exists($handlerClass)) {
            throw new RuntimeException("No handler found for {$commandClass}");
        }

        $handler = $this->container->make($handlerClass);

        return $handler->handle($command);
    }
}

Register it in your service provider:

$this->app->singleton(CommandBus::class, function (Application $app) {
    return new CommandBus($app);
});

Alternatively, use Laravel Jobs as commands. This gives you queue support out of the box:

final class PlaceOrder implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

    public function __construct(
        public readonly string $customerId,
        public readonly array $items,
        public readonly string $shippingAddress,
    ) {}

    public function handle(OrderRepository $orders, EventDispatcher $events): void
    {
        $order = Order::place(
            customerId: $this->customerId,
            items: $this->items,
            shippingAddress: $this->shippingAddress,
        );

        $orders->save($order);
        $events->dispatch(new OrderPlaced($order->id));
    }
}

// In your controller
PlaceOrder::dispatch(
    customerId: auth()->id(),
    items: $request->validated('items'),
    shippingAddress: $request->validated('shipping_address'),
);

Using Jobs as commands works well for write operations that can tolerate asynchronous processing. For operations requiring an immediate response (returning an ID, redirecting), a synchronous command bus is simpler.

A complete Laravel CQRS example: subscription management

Here is a full CQRS implementation for a subscription feature.

Write side

// Command
final class CreateSubscription
{
    public function __construct(
        public readonly string $userId,
        public readonly string $planId,
        public readonly string $paymentMethodId,
    ) {}
}

// Handler
final class CreateSubscriptionHandler
{
    public function __construct(
        private readonly SubscriptionRepository $subscriptions,
        private readonly PaymentGateway $payments,
        private readonly EventDispatcher $events,
    ) {}

    public function handle(CreateSubscription $command): string
    {
        $plan = Plan::findOrFail($command->planId);

        $paymentMethod = $this->payments->getPaymentMethod(
            $command->paymentMethodId
        );

        $subscription = Subscription::create(
            userId: $command->userId,
            plan: $plan,
            paymentMethod: $paymentMethod,
        );

        $this->subscriptions->save($subscription);
        $this->events->dispatch(new SubscriptionCreated($subscription->id));

        return $subscription->id;
    }
}

Read side

// Query
final class GetUserSubscription
{
    public function __construct(
        public readonly string $userId,
    ) {}
}

// DTO
final class SubscriptionDto
{
    public function __construct(
        public readonly string $id,
        public readonly string $planName,
        public readonly string $status,
        public readonly float $monthlyPrice,
        public readonly Carbon $renewsAt,
        public readonly bool $isCanceled,
    ) {}
}

// Handler
final class GetUserSubscriptionHandler
{
    public function handle(GetUserSubscription $query): ?SubscriptionDto
    {
        $row = DB::table('subscription_views')
            ->where('user_id', $query->userId)
            ->whereNot('status', 'expired')
            ->first();

        if (!$row) {
            return null;
        }

        return new SubscriptionDto(
            id: $row->id,
            planName: $row->plan_name,
            status: $row->status,
            monthlyPrice: $row->monthly_price,
            renewsAt: Carbon::parse($row->renews_at),
            isCanceled: (bool) $row->is_canceled,
        );
    }
}

Controller

final class SubscriptionController extends Controller
{
    public function __construct(
        private readonly CommandBus $commands,
        private readonly QueryBus $queries,
    ) {}

    public function store(CreateSubscriptionRequest $request): RedirectResponse
    {
        $subscriptionId = $this->commands->dispatch(
            new CreateSubscription(
                userId: auth()->id(),
                planId: $request->validated('plan_id'),
                paymentMethodId: $request->validated('payment_method_id'),
            )
        );

        return redirect()
            ->route('subscriptions.show', $subscriptionId)
            ->with('success', 'Subscription created.');
    }

    public function show(): View
    {
        $subscription = $this->queries->dispatch(
            new GetUserSubscription(auth()->id())
        );

        return view('subscriptions.show', compact('subscription'));
    }
}

The controller routes intent (commands) and data requests (queries) to the appropriate handlers. No business logic lives here.

Separating the read and write databases

The basic form of CQRS uses one database but different models for reading and writing. The advanced form uses entirely separate databases:

  • Write database: optimized for transactional consistency (PostgreSQL, MySQL)
  • Read database: optimized for query performance (a read replica, Redis, Elasticsearch, or a denormalized projection table)

When a command executes and an event fires, an event listener updates the read model:

final class UpdateSubscriptionReadModel
{
    public function handle(SubscriptionCreated $event): void
    {
        $subscription = Subscription::with('plan', 'user')
            ->findOrFail($event->subscriptionId);

        DB::table('subscription_views')->insert([
            'id' => $subscription->id,
            'user_id' => $subscription->user_id,
            'plan_name' => $subscription->plan->name,
            'status' => $subscription->status,
            'monthly_price' => $subscription->plan->monthly_price,
            'renews_at' => $subscription->renews_at,
            'is_canceled' => false,
        ]);
    }
}

The read model is eventually consistent with the write model. For most features, the delay is imperceptible and acceptable. For operations requiring immediate consistency (confirming a payment to the user who just made it), you need to account for the lag.

CQRS and event sourcing

CQRS and event sourcing are frequently mentioned together, but they are independent patterns. You can use CQRS without event sourcing, and vice versa.

With event sourcing, the write model does not store current state. It stores every change as an immutable event. The current state is derived by replaying those events. CQRS pairs well with event sourcing because the event log on the write side naturally feeds projections (read models) on the query side.

If you are evaluating event sourcing alongside CQRS, see our guide on event sourcing in Laravel for the full picture.

CQRS in the context of microservices

CQRS fits naturally in microservice architectures. Each service can expose a command interface for writes and a query interface for reads. Read models for one service can be populated by events published by another, enabling loose coupling without sacrificing query performance.

This pattern is common in financial services, e-commerce platforms, and complex SaaS products where read and write traffic profiles differ significantly. At Sandorian, we apply CQRS and event sourcing in fintech and SaaS projects where auditability, scalability, and domain complexity justify the added structure. Our software architecture services include helping teams assess and implement CQRS where it genuinely fits.

When CQRS makes sense

CQRS adds complexity. Before adopting it, be honest about whether your situation warrants it.

Good fit:

  • Complex domains where read and write models diverge significantly
  • High-read applications where query performance needs independent optimization
  • Systems using event sourcing
  • Applications with strong audit and compliance requirements
  • Microservices with different read and write load profiles

Poor fit:

  • Simple CRUD applications with straightforward data shapes
  • Small teams that cannot sustain the added maintenance overhead
  • Early-stage products where the domain is still being discovered
  • Operations requiring immediate consistency where eventual consistency is unacceptable

Most teams apply CQRS incrementally, in the parts of the system where it provides clear value, rather than across the entire application from day one.

Key trade-offs

Complexity: Two models to maintain instead of one. More classes, more indirection. The payoff is clarity of intent and independent optimization.

Eventual consistency: With separate read and write stores, the read model lags behind. This is acceptable for most features but not all.

Testing surface: Command handlers and query handlers each require their own tests. This is more test code, but more focused and easier to reason about.

Performance ceiling: Denormalized read models eliminate expensive joins and aggregations at query time. For high-traffic read paths, this improvement is substantial.

Frequently asked questions

What is the CQRS pattern?

CQRS (Command Query Responsibility Segregation) is an architectural pattern that separates write operations (commands) from read operations (queries) into distinct models. Commands express intent to change state. Queries retrieve data without modifying anything. Each model can be designed and optimized independently.

What is the difference between CQS and CQRS?

Command-Query Separation (CQS) is a method-level principle: a method should either change state or return data, not both. CQRS applies this at the architecture level, using separate models and optionally separate databases for reads and writes.

Do I need event sourcing to use CQRS?

No. CQRS and event sourcing are complementary but independent. You can implement CQRS with a traditional relational database, using your domain model for writes and optimized queries or read models for reads. Event sourcing is an optional addition that provides an immutable event log.

Can CQRS work with a single database?

Yes. The simplest form of CQRS uses one database but separates the logic used for reading and writing. Separate databases are an optimization for systems that need it, not a requirement of the pattern.

Why is CQRS a bad choice for simple applications?

CQRS introduces two models, two layers of indirection, and eventual consistency concerns where none existed before. For a simple CRUD application, this overhead provides no benefit. The added complexity slows development and increases maintenance burden without improving user experience or system performance.

How do I handle validation in CQRS?

Input validation (types, required fields, formats) belongs in request objects or command validators before dispatching. Business rule validation (does this user have permission? is this plan available?) belongs in the command handler or domain model. Keep them separate.

Is CQRS suitable for Laravel projects?

Yes. Laravel's service container, event system, and Job dispatch work well with CQRS. The command bus pattern maps cleanly onto Laravel's dependency injection. Query handlers can use the query builder directly, bypassing Eloquent for read-optimized performance where needed.

What packages support CQRS in Laravel?

Most teams implement a simple command and query bus using Laravel's service container rather than reaching for a package. For teams combining CQRS with event sourcing, EventSauce is the recommended PHP library. Spatie maintains several related packages for event sourcing that integrate well with Laravel.

How does CQRS improve scalability?

On the write side, commands can be queued and processed asynchronously, distributing load over time. On the read side, denormalized read models eliminate expensive joins and aggregations, improving query performance under load. With separate databases, read and write infrastructure can be scaled independently based on actual traffic patterns.

How does CQRS relate to Domain-Driven Design (DDD)?

CQRS and DDD are frequently used together. In a DDD context, commands map to use cases in your application layer, and the command handlers coordinate between your domain model and infrastructure. The read model sits outside the domain entirely and talks directly to the database. CQRS gives DDD applications a clean separation between domain logic (write side) and presentation concerns (read side).

Let's talk

We build the systems that power your business. Let us know how we can help.

Book a 30-min Call