Implementing CQRS in Laravel
As a senior software engineer, I've spent a lot of time optimizing Laravel applications. When working on large-scale projects, I realized that the traditional way of handling queries and commands in Laravel can get messy, especially when dealing with 250+ API endpoints, different data sources, and complex business logic. This is where CQRS (Command Query Responsibility Segregation) came in as a lifesaver.
In this article, I'll break down what CQRS is, why you should use it in Laravel, and how to implement it effectively.
What is CQRS?
CQRS is an architectural pattern that separates the read and write operations of an application into two distinct models:
- Commands (Write operations): Modify data but do not return anything.
- Queries (Read operations): Retrieve data without making any modifications.
By default, in Laravel, you might be using Eloquent models for both queries and commands, which works fine for small applications. However, as your application scales, mixing read and write operations can create performance bottlenecks, increase maintenance costs, and make debugging more challenging.
Why Use CQRS in Laravel?
Here’s why I chose to implement CQRS in Laravel:
- Scalability - Separating reads and writes allows for independent scaling.
- Better Maintainability - Having separate services for reading and writing makes the codebase cleaner.
- Improved Performance - Queries can be optimized separately without affecting write operations.
- Multiple Data Sources - You can fetch data from databases, APIs, or caches without affecting writes.
- Enhanced Logging - You can track commands and queries separately for better debugging and auditing.
That said, CQRS does introduce some complexity, especially for small projects. However, if you're building something like Passmate (my driving learning app) or a high-traffic API, it's worth the investment.
Implementing CQRS in Laravel
To demonstrate CQRS in Laravel, let’s build a simple product management system where users can create, update, delete, and retrieve products.
1. Setting Up the Model and Migration
First, let’s create a Product
model and migration:
php artisan make:model Product -m
Edit the migration file:
public function up() { Schema::create('products', function (Blueprint $table) { $table->id(); $table->string('name'); $table->decimal('price', 8, 2); $table->timestamps(); }); }
Run the migration:
php artisan migrate
2. Creating Commands (Handling Write Operations)
CreateProductCommand.php
namespace App\Commands; class CreateProductCommand { private string $name; private float $price; public function __construct(string $name, float $price) { $this->name = $name; $this->price = $price; } public function getName(): string { return $this->name; } public function getPrice(): float { return $this->price; } }
3. Creating Command Handlers with Logging
CreateProductHandler.php
namespace App\Commands; use App\Models\Product; use Illuminate\Support\Facades\Log; class CreateProductHandler { public function __invoke(CreateProductCommand $command) { Log::info("Handling CreateProductCommand", ['name' => $command->getName(), 'price' => $command->getPrice()]); $product = new Product(); $product->name = $command->getName(); $product->price = $command->getPrice(); $product->save(); Log::info("Product Created Successfully", ['id' => $product->id]); } }
Each command must have exactly one handler responsible for executing it.
4. Creating Queries (Handling Read Operations)
ProductQuery.php
namespace App\Queries; use App\Models\Product; use Illuminate\Support\Facades\Log; class ProductQuery { private int $productId; public function __construct(int $productId) { $this->productId = $productId; } public function getData(): array { Log::info("Fetching Product Data", ['product_id' => $this->productId]); $product = Product::findOrFail($this->productId); return [ 'name' => $product->name, 'price' => $product->price, ]; } }
5. Implementing a Command Bus with Logging
To simplify executing commands, we create a CommandBus to resolve handlers dynamically.
CommandBus.php
namespace App; use Illuminate\Support\Facades\App; use Illuminate\Support\Facades\Log; use ReflectionClass; class CommandBus { public function handle($command) { Log::info("Dispatching Command", ['command' => get_class($command)]); $reflection = new ReflectionClass($command); $handlerName = str_replace("Command", "Handler", $reflection->getShortName()); $handler = App::make("App\Commands\" . $handlerName); $handler($command); Log::info("Command Handled Successfully", ['command' => get_class($command)]); } }
This implementation of CQRS in Laravel allows for cleaner, more maintainable code and enables independent scaling of read and write operations. However, it does introduce additional complexity, which may not be ideal for small projects.
Possible Enhancements
- Add More Logging Levels to distinguish between informational logs and errors.
- Implement Transactions to prevent partial updates.
- Handle Multiple Commands at Once for batch operations.
This approach worked wonders for me while building Passmate and other Laravel applications. If you're working on a complex Laravel project, CQRS with logging might be the perfect architecture to keep your codebase manageable and auditable.
Let me know your thoughts! 🚀