Skip to content

CQRS Pattern Guide

Overview

Command Query Responsibility Segregation (CQRS) separates write operations (Commands) from read operations (Queries). This pattern provides clear separation of concerns, explicit use cases, better testability, and independent optimization paths for reads and writes.

The Operations module implements CQRS as the reference implementation for the entire application.

Table of Contents

Core Concepts

Traditional Approach

php
// Traditional: Mixed read/write in repository/service
class ProductService
{
    public function createProduct(array $data): Product
    {
        // Business logic mixed with data access
        DB::beginTransaction();
        $product = Product::create($data);
        // More logic...
        DB::commit();
        return $product;
    }

    public function getProducts(array $filters): Collection
    {
        // Query logic mixed with business logic
        return Product::query()
            ->when($filters['status'] ?? null, fn($q, $status) => $q->where('status', $status))
            ->get();
    }
}

Problems:

  • Mixed responsibilities
  • Hard to test
  • Unclear intent
  • Cannot optimize independently

CQRS Approach

php
// CQRS: Explicit Commands and Queries

// Write Operation
$command = new CreateProductCommand(dto: $dto);
$product = $this->commandBus->dispatch($command);

// Read Operation
$query = new GetProductsQuery(filterDTO: $filterDTO);
$products = $this->queryBus->dispatch($query);

Benefits:

  • Clear separation: writes vs reads
  • Explicit use cases
  • Easy to test
  • Independent optimization
  • Transaction management handled automatically

Commands (Write Operations)

What is a Command?

A Command represents intent to change state:

php
<?php

declare(strict_types=1);

namespace App\Modules\Operations\Application\Commands;

use App\Shared\Application\Commands\Command;

final class CreateProductCommand implements Command
{
    public function __construct(
        public readonly CreateProductDTO $dto,
    ) {}

    public function validate(): array
    {
        // Return validation errors or empty array
        try {
            // DTO validates in constructor
            return [];
        } catch (\InvalidArgumentException $e) {
            return [$e->getMessage()];
        }
    }

    public static function fromArray(array $data): self
    {
        return new self(
            dto: CreateProductDTO::fromArray($data),
        );
    }
}

Command Characteristics:

  • Immutable: Use readonly properties
  • Imperative naming: CreateProduct, UpdateOrder, ActivateUser
  • Contains all data: Everything needed for the operation
  • Validates itself: validate() method returns errors
  • Factory method: fromArray() for easy construction

Command Examples

php
// Create
$command = new CreateProductCommand(
    dto: CreateProductDTO::fromArray($request->validated())
);

// Update
$command = new UpdateProductCommand(
    productId: 42,
    dto: UpdateProductDTO::fromArray($request->validated())
);

// Activate
$command = new ActivateProductCommand(productId: 42);

// Bulk Operation
$command = new BulkUpdateVariantPricesCommand(
    masterProductId: 1,
    price: Money::fromFloat(19.99)
);

Command Handlers

Handlers execute the business logic:

php
<?php

declare(strict_types=1);

namespace App\Modules\Operations\Application\Commands;

use App\Shared\Application\Commands\Command;
use App\Shared\Application\Commands\CommandHandler;
use App\Modules\Operations\Domain\Contracts\ProductRepositoryInterface;

final class CreateProductCommandHandler implements CommandHandler
{
    public function __construct(
        private readonly ProductRepositoryInterface $productRepository,
    ) {}

    public function handle(Command $command): Product
    {
        // Type assertion for IDE support
        \assert($command instanceof CreateProductCommand);

        $dto = $command->dto;
        $data = $dto->toArray();

        // Business logic
        $product = new Product($data);

        // Repository saves and dispatches domain events
        $this->productRepository->save($product);

        return $product;
    }
}

Handler Characteristics:

  • Single responsibility: One command type per handler
  • Business logic container: All domain rules enforced here
  • Uses domain services: Orchestrates domain models and services
  • Transaction wrapped: CommandBus handles transactions automatically
  • Returns result: Entity, DTO, or void

Queries (Read Operations)

What is a Query?

A Query represents intent to fetch data:

php
<?php

declare(strict_types=1);

namespace App\Modules\Operations\Application\Queries;

use App\Shared\Application\Queries\Query;

final class GetProductByIdQuery implements Query
{
    public function __construct(
        public readonly int $productId,
        public readonly array $includes = [],
    ) {}

    public function validate(): array
    {
        if ($this->productId <= 0) {
            return ['Product ID must be a positive integer'];
        }
        return [];
    }
}

Query Characteristics:

  • Immutable: Use readonly properties
  • Interrogative naming: GetProductById, GetProducts, SearchOrders
  • Read-only: Never modifies state
  • Optimized for use case: Specific to UI/API needs
  • Can include relations: Eager loading specified

Query Examples

php
// Get single item
$query = new GetProductByIdQuery(
    productId: 42,
    includes: ['category', 'supplier', 'brand']
);

// Get collection with filters
$query = new GetProductsQuery(
    filterDTO: ProductFilterDTO::fromArray([
        'status' => 'active',
        'category_id' => 5,
        'min_price' => 500,
        'max_price' => 1500,
        'sort_by' => 'price',
        'sort_direction' => 'desc',
        'per_page' => 25,
    ])
);

// Search
$query = new SearchProductsQuery(
    searchTerm: 'laptop',
    filters: ['status' => 'active']
);

// Aggregate
$query = new GetLowStockProductsQuery(
    warehouseId: 1
);

Query Handlers

Handlers retrieve and return data:

php
<?php

declare(strict_types=1);

namespace App\Modules\Operations\Application\Queries;

use App\Shared\Application\Queries\Query;
use App\Shared\Application\Queries\QueryHandler;
use App\Modules\Operations\Domain\Contracts\ProductRepositoryInterface;

final class GetProductByIdQueryHandler implements QueryHandler
{
    public function __construct(
        private readonly ProductRepositoryInterface $productRepository,
        private readonly ProductService $productService,
    ) {}

    public function handle(Query $query): Product
    {
        \assert($query instanceof GetProductByIdQuery);

        $product = $this->productRepository->findOrFail($query->productId);

        if (!empty($query->includes)) {
            $product = $this->productService->loadProductRelationships(
                $product,
                $query->includes
            );
        }

        return $product;
    }
}

Handler Characteristics:

  • Read-only: Never modifies data
  • No transactions: QueryBus doesn't wrap in transactions
  • Uses repositories: Data access abstraction
  • Can use read models: Optimized views for specific queries
  • Returns data: DTOs, collections, entities

Command Bus

The CommandBus dispatches commands to handlers and manages transactions:

php
interface CommandBus
{
    public function dispatch(Command $command): mixed;
}

Usage in Controllers

php
<?php

namespace App\Modules\Operations\Presentation\Controllers\Products;

use App\Modules\Operations\Application\Commands\CreateProductCommand;
use App\Shared\Application\Bus\CommandBus;

final class ProductController extends Controller
{
    public function __construct(
        private readonly CommandBus $commandBus,
    ) {}

    public function store(CreateProductRequest $request): JsonResponse
    {
        $command = CreateProductCommand::fromArray($request->validated());

        // CommandBus automatically wraps in transaction
        $product = $this->commandBus->dispatch($command);

        return response()->json([
            'message' => 'Product created successfully',
            'data' => new ProductResource($product),
        ], 201);
    }
}

Transaction Wrapping

CommandBus automatically handles database transactions:

php
// Inside CommandBus::dispatch()
DB::beginTransaction();

try {
    $result = $handler->handle($command);
    DB::commit();
    return $result;
} catch (\Throwable $e) {
    DB::rollBack();
    throw $e;
}

No manual transaction management needed!

Query Bus

The QueryBus dispatches queries to handlers (without transactions):

php
interface QueryBus
{
    public function dispatch(Query $query): mixed;
}

Usage in Controllers

php
<?php

namespace App\Modules\Operations\Presentation\Controllers\Products;

use App\Modules\Operations\Application\Queries\GetProductsQuery;
use App\Shared\Application\Bus\QueryBus;

final class ProductController extends Controller
{
    public function __construct(
        private readonly QueryBus $queryBus,
    ) {}

    public function index(IndexProductRequest $request): ProductCollection
    {
        $filterDTO = ProductFilterDTO::fromArray($request->getFilters());
        $query = new GetProductsQuery($filterDTO);

        // No transaction wrapping - read-only
        $products = $this->queryBus->dispatch($query);

        return new ProductCollection($products);
    }
}

No transactions for queries - they're read-only.

Handler Discovery

The buses use auto-discovery based on naming convention:

Command: CreateProductCommand
Handler: CreateProductCommandHandler

Command: UpdateProductCommand
Handler: UpdateProductCommandHandler

Query: GetProductByIdQuery
Handler: GetProductByIdQueryHandler

Query: GetProductsQuery
Handler: GetProductsQueryHandler

No manual registration required!

Naming Rules

  1. Commands end with Command
  2. Queries end with Query
  3. Handlers append Handler to command/query name
  4. Handlers in same namespace as command/query (or Handlers subdirectory)

Examples:

✅ CreateProductCommand → CreateProductCommandHandler
✅ GetProductsQuery → GetProductsQueryHandler
❌ CreateProduct → CreateProductHandler (missing Command suffix)
❌ GetProducts → GetProductsHandler (missing Query suffix)

Transaction Management

Commands - Automatic Transactions

php
// ✅ Automatic transaction - No need for DB::beginTransaction()
$command = new CreateProductCommand(dto: $dto);
$product = $this->commandBus->dispatch($command);

// Transaction automatically committed if successful
// Transaction automatically rolled back on exception

Queries - No Transactions

php
// ✅ Read-only - No transaction needed
$query = new GetProductsQuery(filterDTO: $filterDTO);
$products = $this->queryBus->dispatch($query);

Multi-Step Commands

php
// ✅ All wrapped in single transaction
$command1 = new CreateProductCommand(dto: $dto);
$product = $this->commandBus->dispatch($command1);

$command2 = new UpdateInventoryCommand(productId: $product->id, quantity: 100);
$this->commandBus->dispatch($command2);

// Each command gets its own transaction
// If you need atomic multi-step, create single command

Testing

Testing Commands

php
<?php

use Tests\TestCase;
use App\Modules\Operations\Application\Commands\CreateProductCommand;
use App\Modules\Operations\Application\DTOs\Product\CreateProductDTO;

class CreateProductCommandTest extends TestCase
{
    public function test_creates_valid_command_from_array(): void
    {
        $data = [
            'name' => 'Test Product',
            'sku' => 'TEST-001',
            'selling_price' => 99.99,
        ];

        $command = CreateProductCommand::fromArray($data);

        $this->assertInstanceOf(CreateProductCommand::class, $command);
        $this->assertEquals('Test Product', $command->dto->name);
    }

    public function test_validates_command_data(): void
    {
        $data = ['name' => '']; // Invalid: name required

        $this->expectException(\InvalidArgumentException::class);
        CreateProductCommand::fromArray($data);
    }
}

Testing Command Handlers

php
<?php

use Tests\TestCase;
use App\Modules\Operations\Application\Commands\CreateProductCommand;
use App\Modules\Operations\Application\Commands\CreateProductCommandHandler;

class CreateProductCommandHandlerTest extends TestCase
{
    public function test_creates_product_successfully(): void
    {
        $command = new CreateProductCommand(
            dto: CreateProductDTO::fromArray([
                'name' => 'Laptop - Dell XPS 13',
                'sku' => 'DELL-XPS13-001',
                'selling_price' => 999.99,
            ])
        );

        $handler = app(CreateProductCommandHandler::class);
        $product = $handler->handle($command);

        $this->assertInstanceOf(Product::class, $product);
        $this->assertTenantDatabaseHas('products', [
            'name' => 'Laptop - Dell XPS 13',
            'sku' => 'DELL-XPS13-001',
        ]);
    }
}

Testing via Command Bus (Integration)

php
<?php

use Tests\TestCase;
use App\Modules\Operations\Application\Commands\CreateProductCommand;
use App\Shared\Application\Bus\CommandBus;

class ProductCreationIntegrationTest extends TestCase
{
    public function test_creates_product_via_command_bus(): void
    {
        $commandBus = app(CommandBus::class);

        $command = new CreateProductCommand(
            dto: CreateProductDTO::fromArray([
                'name' => 'Laptop - Dell XPS 13',
                'sku' => 'DELL-XPS13-001',
                'selling_price' => 999.99,
            ])
        );

        $product = $commandBus->dispatch($command);

        $this->assertInstanceOf(Product::class, $product);
        $this->assertEquals('Laptop - Dell XPS 13', $product->name);
    }
}

When to Use CQRS

✅ Use CQRS When:

  • Complex business logic requiring clear separation
  • Need to optimize reads and writes independently
  • Want explicit use cases for better maintainability
  • Building core domain features (like Operations module)
  • Team prefers clear command/query distinction
  • Long-term maintenance expected

❌ Keep It Simple When:

  • Basic CRUD operations with minimal business logic
  • Rapid prototyping or proof of concepts
  • Simple administrative panels
  • Learning the system (start simple, refactor to CQRS later)
  • Very simple domains

Rule of Thumb

Use CQRS for core domain features, consider simpler patterns for supporting features.

Operations Module = CQRS ReferenceSimple Admin Panels = Traditional Approach

Operations Module Examples

Product Creation (CQRS)

php
// Command
$command = new CreateProductCommand(
    dto: CreateProductDTO::fromArray($request->validated())
);

$product = $this->commandBus->dispatch($command);

Product Retrieval (CQRS)

php
// Query
$query = new GetProductByIdQuery(
    productId: 42,
    includes: ['category', 'supplier']
);

$product = $this->queryBus->dispatch($query);

Product Activation (CQRS)

php
// Command
$command = new ActivateProductCommand(productId: 42);

$product = $this->commandBus->dispatch($command);

Product Search (CQRS)

php
// Query
$query = new SearchProductsQuery(
    searchTerm: 'laptop',
    filters: ['status' => 'active']
);

$results = $this->queryBus->dispatch($query);

Further Reading

  • Operations Module: app/Modules/Operations/Application/Commands/
  • Operations Module: app/Modules/Operations/Application/Queries/
  • Command Bus: app/Shared/Application/Bus/CommandBus.php
  • Query Bus: app/Shared/Application/Bus/QueryBus.php

Documentation for SynthesQ CRM/ERP Platform