Command Query Responsibility Segregation (CQRS) is an architectural pattern that separates read and write operations in your application. This guide explores how to implement CQRS in Laravel applications to improve scalability, performance, and code organization.
As Laravel applications grow in complexity, traditional CRUD approaches can lead to bloated models and controllers. CQRS offers a solution by splitting your application's operations into two distinct categories: commands that change state and queries that return data. This separation brings numerous benefits, especially for applications with complex business logic or high read/write ratios.
Table Of Contents
- Understanding CQRS Fundamentals
- When to Consider CQRS in Laravel
- Implementing CQRS in Laravel
- Advanced CQRS Patterns
- Testing CQRS Components
- Potential Challenges
- Conclusion
- Related Posts
Understanding CQRS Fundamentals
At its core, CQRS distinguishes between:
- Commands: Write operations that modify state (create, update, delete)
- Queries: Read operations that return data without side effects
This separation allows each side to be optimized independently. For example, your read models can be denormalized for performance, while write models maintain data integrity.
For performance tips and advanced querying, see Advanced Eloquent Techniques and Optimizations in Laravel.
When to Consider CQRS in Laravel
CQRS isn't appropriate for every project. Consider implementing it when:
- Your application has complex domain logic
- Read and write workloads have significantly different performance characteristics
- You need to scale reads and writes independently
- You're working with event sourcing
- You have reporting needs that don't align with your transactional data structure
For simple CRUD applications, CQRS may introduce unnecessary complexity.
Implementing CQRS in Laravel
Let's explore a practical implementation of CQRS in Laravel using a simple e-commerce example.
1. Directory Structure
First, organize your application structure to reflect the CQRS pattern:
app/
├── Commands/ # Write operations
│ ├── Handlers/ # Command handlers
│ └── ... # Command DTOs
├── Queries/ # Read operations
│ ├── Handlers/ # Query handlers
│ └── ... # Query DTOs
├── ReadModels/ # Models optimized for reading
├── WriteModels/ # Models optimized for writing
└── ...
To learn more about modularizing your Laravel application, visit Building Modular Laravel Applications (Domains).
2. Creating Commands
Commands are simple DTOs (Data Transfer Objects) that represent intent to change state:
// app/Commands/CreateOrderCommand.php
namespace App\Commands;
class CreateOrderCommand
{
public function __construct(
public readonly int $userId,
public readonly array $items,
public readonly string $shippingAddress,
public readonly string $billingAddress,
) {}
}
3. Command Handlers
Command handlers contain the logic to process commands:
// app/Commands/Handlers/CreateOrderHandler.php
namespace App\Commands\Handlers;
use App\Commands\CreateOrderCommand;
use App\WriteModels\Order;
use App\Events\OrderCreated;
class CreateOrderHandler
{
public function handle(CreateOrderCommand $command): int
{
$order = new Order();
$order->user_id = $command->userId;
$order->shipping_address = $command->shippingAddress;
$order->billing_address = $command->billingAddress;
$order->status = 'pending';
$order->save();
// Save order items
foreach ($command->items as $item) {
$order->items()->create([
'product_id' => $item['product_id'],
'quantity' => $item['quantity'],
'price' => $item['price']
]);
}
// Dispatch event for further processing
event(new OrderCreated($order));
return $order->id;
}
}
4. Creating Queries
Similar to commands, queries are DTOs that represent data retrieval requests:
// app/Queries/GetOrderDetailsQuery.php
namespace App\Queries;
class GetOrderDetailsQuery
{
public function __construct(
public readonly int $orderId,
public readonly int $userId
) {}
}
5. Query Handlers
Query handlers retrieve and format the requested data:
// app/Queries/Handlers/GetOrderDetailsHandler.php
namespace App\Queries\Handlers;
use App\Queries\GetOrderDetailsQuery;
use App\ReadModels\OrderDetails;
use Illuminate\Database\Eloquent\ModelNotFoundException;
class GetOrderDetailsHandler
{
public function handle(GetOrderDetailsQuery $query)
{
$order = OrderDetails::where('id', $query->orderId)
->where('user_id', $query->userId)
->first();
if (!$order) {
throw new ModelNotFoundException();
}
return $order;
}
}
6. Command and Query Buses
To facilitate CQRS operations, implement command and query buses:
// app/Services/CommandBus.php
namespace App\Services;
use Illuminate\Container\Container;
class CommandBus
{
public function __construct(
protected Container $container
) {}
public function dispatch($command)
{
$handler = $this->resolveHandler($command);
return $handler->handle($command);
}
protected function resolveHandler($command)
{
$handlerClass = get_class($command) . 'Handler';
$handlerClass = str_replace('Commands', 'Commands\\Handlers', $handlerClass);
return $this->container->make($handlerClass);
}
}
The query bus would be implemented similarly.
7. Read and Write Models
Create separate models optimized for reading and writing:
// app/WriteModels/Order.php (Uses the normal database)
namespace App\WriteModels;
use Illuminate\Database\Eloquent\Model;
class Order extends Model
{
// Standard Eloquent model for writing
}
// app/ReadModels/OrderDetails.php (Could use a denormalized view)
namespace App\ReadModels;
use Illuminate\Database\Eloquent\Model;
class OrderDetails extends Model
{
protected $table = 'order_details_view';
// This might be a database view joining orders, items, products, etc.
// Optimized for reading with all the necessary data pre-joined
}
8. Controller Usage
Now you can use these components in your controllers:
// app/Http/Controllers/OrderController.php
namespace App\Http\Controllers;
use App\Commands\CreateOrderCommand;
use App\Queries\GetOrderDetailsQuery;
use App\Services\CommandBus;
use App\Services\QueryBus;
use Illuminate\Http\Request;
class OrderController extends Controller
{
public function __construct(
protected CommandBus $commandBus,
protected QueryBus $queryBus
) {}
public function store(Request $request)
{
$validated = $request->validate([
'items' => 'required|array',
'shipping_address' => 'required|string',
'billing_address' => 'required|string',
]);
$command = new CreateOrderCommand(
auth()->id(),
$validated['items'],
$validated['shipping_address'],
$validated['billing_address']
);
$orderId = $this->commandBus->dispatch($command);
return response()->json(['order_id' => $orderId], 201);
}
public function show($id)
{
$query = new GetOrderDetailsQuery($id, auth()->id());
$order = $this->queryBus->dispatch($query);
return response()->json($order);
}
}
Advanced CQRS Patterns
Event Sourcing with CQRS
CQRS pairs naturally with event sourcing, where state changes are stored as a sequence of events:
// In a command handler with event sourcing
public function handle(UpdateOrderStatusCommand $command)
{
$order = Order::find($command->orderId);
// Instead of directly updating the model
// $order->status = $command->status;
// $order->save();
// Record an event
event(new OrderStatusChanged(
$command->orderId,
$order->status,
$command->status,
auth()->id()
));
}
// Event listener rebuilds the read model
public function handle(OrderStatusChanged $event)
{
// Update the read model
DB::table('order_details_view')
->where('id', $event->orderId)
->update(['status' => $event->newStatus]);
// Other side effects like notifications
if ($event->newStatus === 'shipped') {
Notification::send(
User::find($event->userId),
new OrderShippedNotification($event->orderId)
);
}
}
Separate Databases for Reads and Writes
For high-scale applications, you can use different databases for reads and writes:
// config/database.php
'connections' => [
'mysql_write' => [
'driver' => 'mysql',
'host' => env('DB_WRITE_HOST', '127.0.0.1'),
// ...
],
'mysql_read' => [
'driver' => 'mysql',
'host' => env('DB_READ_HOST', '127.0.0.1'),
// ...
],
]
// app/WriteModels/Order.php
class Order extends Model
{
protected $connection = 'mysql_write';
}
// app/ReadModels/OrderDetails.php
class OrderDetails extends Model
{
protected $connection = 'mysql_read';
}
Testing CQRS Components
One of the benefits of CQRS is improved testability:
public function test_create_order_command_handler()
{
// Arrange
$command = new CreateOrderCommand(
userId: 1,
items: [
['product_id' => 1, 'quantity' => 2, 'price' => 10.00]
],
shippingAddress: '123 Ship St',
billingAddress: '123 Bill St'
);
$handler = new CreateOrderHandler();
// Act
$orderId = $handler->handle($command);
// Assert
$this->assertDatabaseHas('orders', [
'id' => $orderId,
'user_id' => 1,
'shipping_address' => '123 Ship St'
]);
}
Potential Challenges
CQRS isn't without challenges:
- Increased complexity - More moving parts to manage
- Eventual consistency - Read models may lag behind write models
- Learning curve - Team needs to understand the pattern
- Synchronization - Keeping read and write models in sync
Conclusion
CQRS can significantly improve the architecture of complex Laravel applications by separating read and write concerns. This pattern particularly shines in applications with complex business logic, different read/write scaling needs, or when combined with event sourcing.
Remember that CQRS is not an all-or-nothing approach. You can apply it selectively to parts of your application where it provides the most benefit, while keeping simpler CRUD operations as they are.
By understanding the principles behind CQRS and adapting them to your Laravel application's needs, you can build more maintainable, scalable, and performant systems.
Add Comment
No comments yet. Be the first to comment!