Implementing CQRS in Laravel
How I implemented the CQRS pattern in Laravel, the challenges faced, and why it is a game changer for large-scale applications.
When developing large-scale Laravel applications spanning dozens of models, 250+ API endpoints, and complex domain workflows, standard MVC controllers and Eloquent models can easily become overloaded. Mixing read operations, state mutations, cache invalidation, and external notifications in a single layer often produces bloated classes that are difficult to test and maintain.
This is where CQRS (Command Query Responsibility Segregation) proves invaluable. In this dispatch, we explore the core principles of CQRS and walk through a clean, pragmatic implementation in Laravel.
What is CQRS?
CQRS is an architectural pattern that separates operations that mutate state from operations that read state:
- Commands (Write Operations): Actions that alter system state. Commands express business intent (e.g.,
RegisterUser,CreateProduct,CancelSubscription) and do not return domain datasets. - Queries (Read Operations): Requests that retrieve data without modifying state. Queries return DTOs (Data Transfer Objects) or read projections optimized for display.
By separating reads and writes, you can optimize each side independently: write paths can enforce strict transactional integrity, while read paths can leverage denormalized views, caching layers, or read replicas.
Why Use CQRS in Laravel?
- Independent Scaling: Read volume typically exceeds write volume by orders of magnitude. Read models can query replicas or Redis caches without complicating write logic.
- Auditing and Logging: Commands encapsulate discrete business intents, making structured logging, telemetry, and event sourcing straightforward.
- Reduced Cognitive Load: Handlers are small, focused classes with a single responsibility, adhering strictly to SOLID principles.
- Resilience in High-Traffic Systems: Ideal for platforms with intricate domain rules, such as reservation engines or high-throughput APIs.
Implementing CQRS: A Practical Laravel Walkthrough
To demonstrate CQRS in Laravel, let us build a clean product management workflow where write mutations and read retrievals are decoupled.
1. The Eloquent Model and Migration
First, define the foundational database schema:
Code • bashphp artisan make:model Product -m
Code • php// database/migrations/xxxx_create_products_table.php use Illuminate\Database\Migrations\Migration; use Illuminate\Database\Schema\Blueprint; use Illuminate\Support\Facades\Schema; return new class extends Migration { public function up(): void { Schema::create('products', function (Blueprint $table) { $table->id(); $table->string('name'); $table->decimal('price', 10, 2); $table->string('sku')->unique(); $table->timestamps(); }); } public function down(): void { Schema::dropIfExists('products'); } };
Run the migration:
Code • bashphp artisan migrate
2. Defining the Command (Write Intent)
Commands are immutable data containers carrying the parameters required to perform an action:
Code • php// app/Commands/CreateProductCommand.php namespace App\Commands; readonly class CreateProductCommand { public function __construct( public string $name, public float $price, public string $sku ) {} }
3. Creating the Command Handler
Each command has exactly one handler responsible for executing the mutation and managing transaction boundaries:
Code • php// app/Commands/CreateProductHandler.php namespace App\Commands; use App\Models\Product; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Log; class CreateProductHandler { public function __invoke(CreateProductCommand $command): Product { Log::info("Executing CreateProductCommand", [ 'sku' => $command->sku, 'name' => $command->name, ]); return DB::transaction(function () use ($command) { $product = Product::create([ 'name' => $command->name, 'price' => $command->price, 'sku' => $command->sku, ]); Log::info("Product created successfully", ['id' => $product->id]); return $product; }); } }
4. Defining the Query (Read Operation)
Queries encapsulate retrieval logic and return optimized projection arrays:
Code • php// app/Queries/GetProductByIdQuery.php namespace App\Queries; use App\Models\Product; use Illuminate\Support\Facades\Log; readonly class GetProductByIdQuery { public function __construct(public int $productId) {} public function fetch(): array { Log::info("Fetching product read model", ['id' => $this->productId]); $product = Product::findOrFail($this->productId); return [ 'id' => $product->id, 'name' => $product->name, 'price' => (float) $product->price, 'sku' => $product->sku, ]; } }
5. Dispatching via a Simple Command Bus
A lightweight command bus resolves and executes the correct handler dynamically:
Code • php// app/Bus/CommandBus.php namespace App\Bus; use Illuminate\Support\Facades\App; use Illuminate\Support\Facades\Log; use ReflectionClass; class CommandBus { public function dispatch(object $command): mixed { $reflection = new ReflectionClass($command); $shortName = $reflection->getShortName(); $handlerClassName = str_replace('Command', 'Handler', $shortName); $handlerNamespace = 'App\\Commands\\' . $handlerClassName; Log::info("Dispatching command via bus", ['command' => $shortName]); $handler = App::make($handlerNamespace); return $handler($command); } }
Controllers become thin coordinators:
Code • php// app/Http/Controllers/ProductController.php namespace App\Http\Controllers; use App\Bus\CommandBus; use App\Commands\CreateProductCommand; use App\Queries\GetProductByIdQuery; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; class ProductController extends Controller { public function store(Request $request, CommandBus $bus): JsonResponse { $validated = $request->validate([ 'name' => 'required|string|max:255', 'price' => 'required|numeric|min:0', 'sku' => 'required|string|unique:products,sku', ]); $command = new CreateProductCommand( $validated['name'], (float) $validated['price'], $validated['sku'] ); $product = $bus->dispatch($command); return response()->json(['id' => $product->id], 201); } public function show(int $id): JsonResponse { $query = new GetProductByIdQuery($id); return response()->json($query->fetch()); } }
Conclusion & Architectural Trade-offs
CQRS is not necessary for every simple CRUD application; applying it universally to modest administrative dashboards can introduce unwarranted boilerplate.
However, when an application reaches critical scale—with distinct read and write performance requirements, rigorous auditing needs, or complex asynchronous queues—CQRS brings sanity, testability, and architectural discipline to the Laravel codebase.
Ersin Demirtas
Software Engineer based in London, UK. Building web applications, APIs, and tools with Laravel, TypeScript, React, and Next.js.
Related Articles
Understanding and Solving the N+1 Problem in Laravel
May 20, 2025 —Learn about the N+1 problem, why it degrades application performance, and how to detect and resolve it in Laravel using eager loading.
What Are the 4 Object-Oriented Principles?
Apr 11, 2025 —A clear exploration of the four foundational object-oriented programming principles and how they directly apply to modern Laravel applications.