Implement Command Query Responsibility Segregation (CQRS) in Laravel applications pragmatically, separating reads and writes while maintaining simplicity and avoiding architectural complexity.
Table Of Contents
- Understanding CQRS in Laravel Context
- Simple Command Pattern Implementation
- Query Pattern Implementation
- Event-Driven Architecture Integration
- Controller Integration
- Testing CQRS Implementation
Understanding CQRS in Laravel Context
Command Query Responsibility Segregation (CQRS) is an architectural pattern that separates read operations (queries) from write operations (commands). While CQRS can provide significant benefits for complex applications, it's often overengineered in Laravel implementations, leading to unnecessary complexity and maintenance overhead.
The key to successful CQRS implementation is knowing when and how to apply it judiciously. Start with simple separation of concerns and gradually evolve your architecture based on actual needs rather than theoretical benefits. This approach works particularly well in domain-driven Laravel applications where business logic complexity justifies the architectural overhead.
Simple Command Pattern Implementation
Basic Command Structure
Start with a simple command pattern that doesn't require complex infrastructure:
<?php
namespace App\Commands;
interface CommandInterface
{
public function handle(): mixed;
}
abstract class BaseCommand implements CommandInterface
{
protected array $data;
protected array $rules = [];
public function __construct(array $data = [])
{
$this->data = $data;
$this->validate();
}
protected function validate(): void
{
if (!empty($this->rules)) {
$validator = \Illuminate\Support\Facades\Validator::make($this->data, $this->rules);
if ($validator->fails()) {
throw new \Illuminate\Validation\ValidationException($validator);
}
}
}
protected function get(string $key, mixed $default = null): mixed
{
return data_get($this->data, $key, $default);
}
public function getData(): array
{
return $this->data;
}
}
// Example: User Registration Command
namespace App\Commands\User;
use App\Commands\BaseCommand;
use App\Models\User;
use App\Events\UserRegistered;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\DB;
class CreateUserCommand extends BaseCommand
{
protected array $rules = [
'name' => 'required|string|max:255',
'email' => 'required|email|unique:users,email',
'password' => 'required|string|min:8',
'role' => 'nullable|string|exists:roles,name',
];
public function handle(): User
{
return DB::transaction(function () {
$user = User::create([
'name' => $this->get('name'),
'email' => $this->get('email'),
'password' => Hash::make($this->get('password')),
'email_verified_at' => null,
]);
if ($roleString = $this->get('role')) {
$role = \App\Models\Role::where('name', $roleString)->first();
if ($role) {
$user->roles()->attach($role);
}
}
// Dispatch domain event
event(new UserRegistered($user));
return $user->fresh();
});
}
}
// Order Processing Command
namespace App\Commands\Order;
use App\Commands\BaseCommand;
use App\Models\Order;
use App\Models\Product;
use App\Services\InventoryService;
use App\Services\PaymentService;
use App\Events\OrderCreated;
class ProcessOrderCommand extends BaseCommand
{
protected array $rules = [
'user_id' => 'required|exists:users,id',
'items' => 'required|array|min:1',
'items.*.product_id' => 'required|exists:products,id',
'items.*.quantity' => 'required|integer|min:1',
'payment_method' => 'required|string',
'shipping_address' => 'required|array',
];
protected InventoryService $inventoryService;
protected PaymentService $paymentService;
public function __construct(array $data, InventoryService $inventoryService, PaymentService $paymentService)
{
$this->inventoryService = $inventoryService;
$this->paymentService = $paymentService;
parent::__construct($data);
}
public function handle(): Order
{
return DB::transaction(function () {
// Create order
$order = Order::create([
'user_id' => $this->get('user_id'),
'status' => Order::STATUS_PENDING,
'total_amount' => 0,
'shipping_address' => $this->get('shipping_address'),
]);
$totalAmount = 0;
// Process order items
foreach ($this->get('items') as $item) {
$product = Product::findOrFail($item['product_id']);
// Check inventory
if (!$this->inventoryService->isAvailable($product->id, $item['quantity'])) {
throw new \Exception("Insufficient inventory for product: {$product->name}");
}
// Create order item
$orderItem = $order->items()->create([
'product_id' => $product->id,
'quantity' => $item['quantity'],
'unit_price' => $product->price,
'total_price' => $product->price * $item['quantity'],
]);
$totalAmount += $orderItem->total_price;
// Reserve inventory
$this->inventoryService->reserve($product->id, $item['quantity']);
}
// Update order total
$order->update(['total_amount' => $totalAmount]);
// Process payment
$paymentResult = $this->paymentService->charge(
$totalAmount,
$this->get('payment_method'),
['order_id' => $order->id]
);
if (!$paymentResult->successful) {
throw new \Exception("Payment failed: {$paymentResult->message}");
}
// Update order status
$order->update([
'status' => Order::STATUS_CONFIRMED,
'payment_reference' => $paymentResult->reference,
]);
event(new OrderCreated($order));
return $order->fresh(['items.product']);
});
}
}
Command Handler System
Create a simple command handler system without complex message buses:
<?php
namespace App\Services;
use App\Commands\CommandInterface;
use Illuminate\Container\Container;
use Illuminate\Support\Facades\Log;
class CommandHandler
{
protected Container $container;
protected array $middleware = [];
public function __construct(Container $container)
{
$this->container = $container;
}
public function handle(CommandInterface $command): mixed
{
$this->logCommand($command);
try {
// Apply middleware
$result = $this->executeWithMiddleware($command);
$this->logSuccess($command, $result);
return $result;
} catch (\Exception $e) {
$this->logError($command, $e);
throw $e;
}
}
protected function executeWithMiddleware(CommandInterface $command): mixed
{
$pipeline = array_reduce(
array_reverse($this->middleware),
function ($carry, $middleware) {
return function ($command) use ($carry, $middleware) {
return $this->container->make($middleware)->handle($command, $carry);
};
},
function ($command) {
return $command->handle();
}
);
return $pipeline($command);
}
public function addMiddleware(string $middleware): self
{
$this->middleware[] = $middleware;
return $this;
}
protected function logCommand(CommandInterface $command): void
{
Log::info('Executing command', [
'command' => get_class($command),
'data' => method_exists($command, 'getData') ? $command->getData() : [],
]);
}
protected function logSuccess(CommandInterface $command, mixed $result): void
{
Log::info('Command executed successfully', [
'command' => get_class($command),
'result_type' => is_object($result) ? get_class($result) : gettype($result),
]);
}
protected function logError(CommandInterface $command, \Exception $e): void
{
Log::error('Command execution failed', [
'command' => get_class($command),
'error' => $e->getMessage(),
'trace' => $e->getTraceAsString(),
]);
}
}
// Command Middleware
namespace App\Commands\Middleware;
use App\Commands\CommandInterface;
use Illuminate\Support\Facades\Cache;
class CommandCacheMiddleware
{
public function handle(CommandInterface $command, \Closure $next): mixed
{
// Only cache read-like commands
if (!$this->isCacheable($command)) {
return $next($command);
}
$cacheKey = $this->getCacheKey($command);
return Cache::remember($cacheKey, 300, function () use ($command, $next) {
return $next($command);
});
}
protected function isCacheable(CommandInterface $command): bool
{
// Implement logic to determine if command should be cached
return str_contains(get_class($command), 'Query') ||
str_contains(get_class($command), 'Get');
}
protected function getCacheKey(CommandInterface $command): string
{
return 'command:' . md5(get_class($command) . serialize($command->getData()));
}
}
class CommandValidationMiddleware
{
public function handle(CommandInterface $command, \Closure $next): mixed
{
// Additional validation logic if needed
if (method_exists($command, 'authorize') && !$command->authorize()) {
throw new \Illuminate\Auth\Access\AuthorizationException('Unauthorized command execution');
}
return $next($command);
}
}
Query Pattern Implementation
Read Model Structure
Implement dedicated read models for complex queries:
<?php
namespace App\Queries;
interface QueryInterface
{
public function execute(): mixed;
}
abstract class BaseQuery implements QueryInterface
{
protected array $criteria = [];
protected array $filters = [];
protected ?int $limit = null;
protected int $offset = 0;
public function where(string $field, mixed $value, string $operator = '='): self
{
$this->criteria[] = [$field, $operator, $value];
return $this;
}
public function filter(string $name, mixed $value): self
{
$this->filters[$name] = $value;
return $this;
}
public function limit(int $limit): self
{
$this->limit = $limit;
return $this;
}
public function offset(int $offset): self
{
$this->offset = $offset;
return $this;
}
protected function applyFilters($query)
{
foreach ($this->criteria as [$field, $operator, $value]) {
$query->where($field, $operator, $value);
}
if ($this->limit) {
$query->limit($this->limit);
}
if ($this->offset) {
$query->offset($this->offset);
}
return $query;
}
}
// User Dashboard Query
namespace App\Queries\User;
use App\Queries\BaseQuery;
use App\Models\User;
use Illuminate\Support\Facades\DB;
class UserDashboardQuery extends BaseQuery
{
protected int $userId;
public function __construct(int $userId)
{
$this->userId = $userId;
}
public function execute(): array
{
return [
'user' => $this->getUserData(),
'stats' => $this->getUserStats(),
'recent_orders' => $this->getRecentOrders(),
'notifications' => $this->getNotifications(),
];
}
protected function getUserData(): array
{
return User::select(['id', 'name', 'email', 'created_at'])
->where('id', $this->userId)
->first()
->toArray();
}
protected function getUserStats(): array
{
$stats = DB::select("
SELECT
COUNT(DISTINCT o.id) as total_orders,
COALESCE(SUM(o.total_amount), 0) as total_spent,
COUNT(DISTINCT CASE WHEN o.created_at >= DATE_SUB(NOW(), INTERVAL 30 DAY) THEN o.id END) as orders_last_month,
(SELECT COUNT(*) FROM user_favorites WHERE user_id = ?) as favorite_products
FROM orders o
WHERE o.user_id = ?
", [$this->userId, $this->userId]);
return (array) $stats[0];
}
protected function getRecentOrders(): array
{
return DB::select("
SELECT
o.id,
o.status,
o.total_amount,
o.created_at,
COUNT(oi.id) as item_count
FROM orders o
LEFT JOIN order_items oi ON o.id = oi.order_id
WHERE o.user_id = ?
GROUP BY o.id, o.status, o.total_amount, o.created_at
ORDER BY o.created_at DESC
LIMIT 10
", [$this->userId]);
}
protected function getNotifications(): array
{
return DB::select("
SELECT id, type, data, read_at, created_at
FROM notifications
WHERE notifiable_type = 'App\\\\Models\\\\User'
AND notifiable_id = ?
ORDER BY created_at DESC
LIMIT 5
", [$this->userId]);
}
}
// Product Search Query
namespace App\Queries\Product;
use App\Queries\BaseQuery;
use Illuminate\Support\Facades\DB;
class ProductSearchQuery extends BaseQuery
{
protected ?string $searchTerm = null;
protected ?int $categoryId = null;
protected ?float $minPrice = null;
protected ?float $maxPrice = null;
protected array $sortBy = ['name', 'asc'];
public function search(string $term): self
{
$this->searchTerm = $term;
return $this;
}
public function inCategory(int $categoryId): self
{
$this->categoryId = $categoryId;
return $this;
}
public function priceRange(?float $min = null, ?float $max = null): self
{
$this->minPrice = $min;
$this->maxPrice = $max;
return $this;
}
public function sortBy(string $field, string $direction = 'asc'): self
{
$this->sortBy = [$field, $direction];
return $this;
}
public function execute(): array
{
$query = $this->buildQuery();
return [
'products' => $query->get()->toArray(),
'total' => $this->getTotalCount(),
'facets' => $this->getFacets(),
];
}
protected function buildQuery()
{
$query = DB::table('products as p')
->select([
'p.id',
'p.name',
'p.slug',
'p.price',
'p.image_url',
'p.rating',
'p.review_count',
'c.name as category_name',
])
->leftJoin('categories as c', 'p.category_id', '=', 'c.id')
->where('p.is_active', true);
if ($this->searchTerm) {
$query->where(function ($q) {
$q->where('p.name', 'LIKE', "%{$this->searchTerm}%")
->orWhere('p.description', 'LIKE', "%{$this->searchTerm}%")
->orWhere('p.tags', 'LIKE', "%{$this->searchTerm}%");
});
}
if ($this->categoryId) {
$query->where('p.category_id', $this->categoryId);
}
if ($this->minPrice !== null) {
$query->where('p.price', '>=', $this->minPrice);
}
if ($this->maxPrice !== null) {
$query->where('p.price', '<=', $this->maxPrice);
}
$query = $this->applyFilters($query);
$query->orderBy("p.{$this->sortBy[0]}", $this->sortBy[1]);
return $query;
}
protected function getTotalCount(): int
{
$query = $this->buildQuery();
return $query->count();
}
protected function getFacets(): array
{
return [
'categories' => $this->getCategoryFacets(),
'price_ranges' => $this->getPriceRangeFacets(),
'brands' => $this->getBrandFacets(),
];
}
protected function getCategoryFacets(): array
{
return DB::select("
SELECT c.id, c.name, COUNT(p.id) as product_count
FROM categories c
LEFT JOIN products p ON c.id = p.category_id AND p.is_active = 1
GROUP BY c.id, c.name
HAVING product_count > 0
ORDER BY c.name
");
}
protected function getPriceRangeFacets(): array
{
return [
['range' => '0-25', 'label' => 'Under $25', 'count' => $this->countInPriceRange(0, 25)],
['range' => '25-50', 'label' => '$25 - $50', 'count' => $this->countInPriceRange(25, 50)],
['range' => '50-100', 'label' => '$50 - $100', 'count' => $this->countInPriceRange(50, 100)],
['range' => '100+', 'label' => '$100+', 'count' => $this->countInPriceRange(100, null)],
];
}
protected function countInPriceRange(float $min, ?float $max): int
{
$query = DB::table('products')->where('is_active', true)->where('price', '>=', $min);
if ($max !== null) {
$query->where('price', '<=', $max);
}
return $query->count();
}
protected function getBrandFacets(): array
{
return DB::select("
SELECT brand, COUNT(*) as product_count
FROM products
WHERE is_active = 1 AND brand IS NOT NULL
GROUP BY brand
ORDER BY product_count DESC
LIMIT 10
");
}
}
Event-Driven Architecture Integration
Domain Events with Simple Implementation
Integrate CQRS with event-driven patterns without complex infrastructure:
<?php
namespace App\Events\Domain;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
abstract class DomainEvent
{
use Dispatchable, SerializesModels;
public \DateTime $occurredAt;
public function __construct()
{
$this->occurredAt = new \DateTime();
}
abstract public function getAggregateId(): string;
abstract public function getEventName(): string;
}
// User Domain Events
namespace App\Events\Domain\User;
use App\Events\Domain\DomainEvent;
use App\Models\User;
class UserRegistered extends DomainEvent
{
public User $user;
public function __construct(User $user)
{
parent::__construct();
$this->user = $user;
}
public function getAggregateId(): string
{
return (string) $this->user->id;
}
public function getEventName(): string
{
return 'user.registered';
}
}
class UserProfileUpdated extends DomainEvent
{
public User $user;
public array $changedFields;
public function __construct(User $user, array $changedFields)
{
parent::__construct();
$this->user = $user;
$this->changedFields = $changedFields;
}
public function getAggregateId(): string
{
return (string) $this->user->id;
}
public function getEventName(): string
{
return 'user.profile_updated';
}
}
// Order Domain Events
namespace App\Events\Domain\Order;
use App\Events\Domain\DomainEvent;
use App\Models\Order;
class OrderCreated extends DomainEvent
{
public Order $order;
public function __construct(Order $order)
{
parent::__construct();
$this->order = $order;
}
public function getAggregateId(): string
{
return (string) $this->order->id;
}
public function getEventName(): string
{
return 'order.created';
}
}
class OrderStatusChanged extends DomainEvent
{
public Order $order;
public string $previousStatus;
public string $newStatus;
public function __construct(Order $order, string $previousStatus, string $newStatus)
{
parent::__construct();
$this->order = $order;
$this->previousStatus = $previousStatus;
$this->newStatus = $newStatus;
}
public function getAggregateId(): string
{
return (string) $this->order->id;
}
public function getEventName(): string
{
return 'order.status_changed';
}
}
// Event Listeners for Read Model Updates
namespace App\Listeners;
use App\Events\Domain\Order\OrderCreated;
use App\Services\ReadModelService;
class UpdateOrderReadModel
{
protected ReadModelService $readModelService;
public function __construct(ReadModelService $readModelService)
{
$this->readModelService = $readModelService;
}
public function handle(OrderCreated $event): void
{
// Update denormalized read models
$this->readModelService->updateUserOrderSummary($event->order->user_id);
$this->readModelService->updateProductPopularity($event->order);
$this->readModelService->updateDailySalesReport($event->order);
}
}
// Read Model Service
namespace App\Services;
use App\Models\Order;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Cache;
class ReadModelService
{
public function updateUserOrderSummary(int $userId): void
{
$summary = DB::selectOne("
SELECT
user_id,
COUNT(*) as total_orders,
SUM(total_amount) as total_spent,
AVG(total_amount) as average_order_value,
MAX(created_at) as last_order_date
FROM orders
WHERE user_id = ? AND status != 'cancelled'
GROUP BY user_id
", [$userId]);
if ($summary) {
DB::table('user_order_summaries')->updateOrInsert(
['user_id' => $userId],
(array) $summary
);
}
// Invalidate cache
Cache::forget("user_summary:{$userId}");
}
public function updateProductPopularity(Order $order): void
{
foreach ($order->items as $item) {
DB::table('product_popularity')->updateOrInsert(
['product_id' => $item->product_id],
[
'product_id' => $item->product_id,
'order_count' => DB::raw('order_count + 1'),
'quantity_sold' => DB::raw("quantity_sold + {$item->quantity}"),
'revenue' => DB::raw("revenue + {$item->total_price}"),
'updated_at' => now(),
]
);
}
}
public function updateDailySalesReport(Order $order): void
{
$date = $order->created_at->format('Y-m-d');
DB::table('daily_sales_reports')->updateOrInsert(
['report_date' => $date],
[
'report_date' => $date,
'order_count' => DB::raw('order_count + 1'),
'total_revenue' => DB::raw("total_revenue + {$order->total_amount}"),
'updated_at' => now(),
]
);
}
}
Controller Integration
CQRS-Aware Controllers
Structure controllers to properly separate commands and queries:
<?php
namespace App\Http\Controllers;
use App\Commands\User\CreateUserCommand;
use App\Commands\User\UpdateUserProfileCommand;
use App\Queries\User\UserDashboardQuery;
use App\Queries\User\UserListQuery;
use App\Services\CommandHandler;
use Illuminate\Http\Request;
use Illuminate\Http\JsonResponse;
class UserController extends Controller
{
protected CommandHandler $commandHandler;
public function __construct(CommandHandler $commandHandler)
{
$this->commandHandler = $commandHandler;
}
// Command Methods (Write Operations)
public function store(Request $request): JsonResponse
{
$command = new CreateUserCommand($request->validated());
$user = $this->commandHandler->handle($command);
return response()->json([
'data' => $user,
'message' => 'User created successfully',
], 201);
}
public function update(Request $request, int $userId): JsonResponse
{
$command = new UpdateUserProfileCommand(
array_merge($request->validated(), ['id' => $userId])
);
$user = $this->commandHandler->handle($command);
return response()->json([
'data' => $user,
'message' => 'User updated successfully',
]);
}
// Query Methods (Read Operations)
public function index(Request $request): JsonResponse
{
$query = new UserListQuery();
if ($search = $request->query('search')) {
$query->search($search);
}
if ($role = $request->query('role')) {
$query->filterByRole($role);
}
$query->limit($request->query('limit', 20))
->offset($request->query('offset', 0));
$result = $query->execute();
return response()->json($result);
}
public function dashboard(int $userId): JsonResponse
{
$query = new UserDashboardQuery($userId);
$dashboard = $query->execute();
return response()->json(['data' => $dashboard]);
}
}
// Order Controller
namespace App\Http\Controllers;
use App\Commands\Order\ProcessOrderCommand;
use App\Commands\Order\UpdateOrderStatusCommand;
use App\Queries\Order\OrderListQuery;
use App\Queries\Order\OrderDetailsQuery;
use App\Services\CommandHandler;
use App\Services\InventoryService;
use App\Services\PaymentService;
class OrderController extends Controller
{
protected CommandHandler $commandHandler;
public function __construct(CommandHandler $commandHandler)
{
$this->commandHandler = $commandHandler;
}
public function store(Request $request): JsonResponse
{
$command = new ProcessOrderCommand(
$request->validated(),
app(InventoryService::class),
app(PaymentService::class)
);
try {
$order = $this->commandHandler->handle($command);
return response()->json([
'data' => $order,
'message' => 'Order processed successfully',
], 201);
} catch (\Exception $e) {
return response()->json([
'error' => 'Order processing failed',
'message' => $e->getMessage(),
], 400);
}
}
public function updateStatus(Request $request, int $orderId): JsonResponse
{
$command = new UpdateOrderStatusCommand([
'order_id' => $orderId,
'status' => $request->input('status'),
'notes' => $request->input('notes'),
]);
$order = $this->commandHandler->handle($command);
return response()->json([
'data' => $order,
'message' => 'Order status updated successfully',
]);
}
public function index(Request $request): JsonResponse
{
$query = new OrderListQuery();
if ($status = $request->query('status')) {
$query->filterByStatus($status);
}
if ($userId = $request->query('user_id')) {
$query->filterByUser($userId);
}
if ($dateRange = $request->query('date_range')) {
[$start, $end] = explode(',', $dateRange);
$query->dateRange($start, $end);
}
$result = $query->execute();
return response()->json($result);
}
public function show(int $orderId): JsonResponse
{
$query = new OrderDetailsQuery($orderId);
$order = $query->execute();
return response()->json(['data' => $order]);
}
}
Testing CQRS Implementation
Command and Query Testing
Test commands and queries independently:
<?php
namespace Tests\Unit\Commands\User;
use Tests\TestCase;
use App\Commands\User\CreateUserCommand;
use App\Models\User;
use App\Models\Role;
use App\Events\Domain\User\UserRegistered;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Hash;
class CreateUserCommandTest extends TestCase
{
use RefreshDatabase;
public function test_creates_user_successfully(): void
{
Event::fake();
$role = Role::factory()->create(['name' => 'customer']);
$command = new CreateUserCommand([
'name' => 'John Doe',
'email' => 'john@example.com',
'password' => 'password123',
'role' => 'customer',
]);
$user = $command->handle();
$this->assertInstanceOf(User::class, $user);
$this->assertEquals('John Doe', $user->name);
$this->assertEquals('john@example.com', $user->email);
$this->assertTrue(Hash::check('password123', $user->password));
$this->assertTrue($user->roles->contains($role));
Event::assertDispatched(UserRegistered::class, function ($event) use ($user) {
return $event->user->id === $user->id;
});
}
public function test_validates_required_fields(): void
{
$this->expectException(\Illuminate\Validation\ValidationException::class);
new CreateUserCommand([
'email' => 'invalid-email',
]);
}
public function test_validates_unique_email(): void
{
User::factory()->create(['email' => 'existing@example.com']);
$this->expectException(\Illuminate\Validation\ValidationException::class);
new CreateUserCommand([
'name' => 'John Doe',
'email' => 'existing@example.com',
'password' => 'password123',
]);
}
}
// Query Testing
namespace Tests\Unit\Queries\User;
use Tests\TestCase;
use App\Queries\User\UserDashboardQuery;
use App\Models\User;
use App\Models\Order;
use Illuminate\Foundation\Testing\RefreshDatabase;
class UserDashboardQueryTest extends TestCase
{
use RefreshDatabase;
public function test_returns_complete_dashboard_data(): void
{
$user = User::factory()->create();
$orders = Order::factory()->count(3)->create([
'user_id' => $user->id,
'total_amount' => 100.00,
]);
$query = new UserDashboardQuery($user->id);
$result = $query->execute();
$this->assertArrayHasKey('user', $result);
$this->assertArrayHasKey('stats', $result);
$this->assertArrayHasKey('recent_orders', $result);
$this->assertArrayHasKey('notifications', $result);
$this->assertEquals($user->id, $result['user']['id']);
$this->assertEquals(3, $result['stats']['total_orders']);
$this->assertEquals(300.00, $result['stats']['total_spent']);
}
public function test_handles_user_with_no_orders(): void
{
$user = User::factory()->create();
$query = new UserDashboardQuery($user->id);
$result = $query->execute();
$this->assertEquals(0, $result['stats']['total_orders']);
$this->assertEquals(0, $result['stats']['total_spent']);
$this->assertEmpty($result['recent_orders']);
}
}
// Integration Testing
namespace Tests\Feature\Controllers;
use Tests\TestCase;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
class UserControllerTest extends TestCase
{
use RefreshDatabase;
public function test_create_user_command_integration(): void
{
$response = $this->postJson('/api/users', [
'name' => 'John Doe',
'email' => 'john@example.com',
'password' => 'password123',
'password_confirmation' => 'password123',
]);
$response->assertStatus(201)
->assertJsonStructure([
'data' => ['id', 'name', 'email', 'created_at'],
'message'
]);
$this->assertDatabaseHas('users', [
'name' => 'John Doe',
'email' => 'john@example.com',
]);
}
public function test_user_dashboard_query_integration(): void
{
$user = User::factory()->create();
$response = $this->actingAs($user)
->getJson("/api/users/{$user->id}/dashboard");
$response->assertStatus(200)
->assertJsonStructure([
'data' => [
'user' => ['id', 'name', 'email'],
'stats' => ['total_orders', 'total_spent'],
'recent_orders',
'notifications'
]
]);
}
}
Implementing CQRS in Laravel doesn't require complex infrastructure or over-engineered solutions. Start with simple command and query separation, gradually adding sophistication based on actual needs. This pragmatic approach maintains the benefits of CQRS while avoiding unnecessary complexity that can plague large Laravel applications.
Add Comment
No comments yet. Be the first to comment!