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
- Commands (Write Operations)
- Queries (Read Operations)
- Command Bus
- Query Bus
- Handler Discovery
- Transaction Management
- Testing
- When to Use CQRS
Core Concepts
Traditional Approach
// 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
// 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
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
readonlyproperties - 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
// 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
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
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
readonlyproperties - 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
// 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
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:
interface CommandBus
{
public function dispatch(Command $command): mixed;
}Usage in Controllers
<?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:
// 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):
interface QueryBus
{
public function dispatch(Query $query): mixed;
}Usage in Controllers
<?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: GetProductsQueryHandlerNo manual registration required!
Naming Rules
- Commands end with
Command - Queries end with
Query - Handlers append
Handlerto command/query name - 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
// ✅ 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 exceptionQueries - No Transactions
// ✅ Read-only - No transaction needed
$query = new GetProductsQuery(filterDTO: $filterDTO);
$products = $this->queryBus->dispatch($query);Multi-Step Commands
// ✅ 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 commandTesting
Testing Commands
<?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
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
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)
// Command
$command = new CreateProductCommand(
dto: CreateProductDTO::fromArray($request->validated())
);
$product = $this->commandBus->dispatch($command);Product Retrieval (CQRS)
// Query
$query = new GetProductByIdQuery(
productId: 42,
includes: ['category', 'supplier']
);
$product = $this->queryBus->dispatch($query);Product Activation (CQRS)
// Command
$command = new ActivateProductCommand(productId: 42);
$product = $this->commandBus->dispatch($command);Product Search (CQRS)
// Query
$query = new SearchProductsQuery(
searchTerm: 'laptop',
filters: ['status' => 'active']
);
$results = $this->queryBus->dispatch($query);Related Patterns
- Domain Events - Events emitted by commands
- Repository Pattern - Data access in handlers
- Aggregates - Product aggregate in commands
- Value Objects - DTOs use Value Objects
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