Skip to content
NOCACHE_PLACEHOLDER
Alpesh Nakrani

Implementing CQRS in a Laravel application (updated 2025)

By

Learn how to implement CQRS in a Laravel application with real code examples. Separate reads from writes, scale independently, and build cleaner architecture.

Implementing CQRS in a Laravel application (updated 2025)

Most Laravel applications start simple. One controller, one model, one save() call. Then the business grows, the read requirements diverge from write requirements, and suddenly your OrderController has 400 lines of logic handling both inventory queries and payment mutations. That is the moment CQRS starts making sense.

Command Query Responsibility Segregation (CQRS) separates your application's read operations (queries) from its write operations (commands). In a Laravel context, this means distinct classes for fetching data and distinct classes for modifying it. The result: leaner controllers, testable business logic, and an architecture that scales when your read load and write load grow at different rates.

I have been implementing CQRS patterns across several Laravel projects at Laracopilot and Devlyn.ai. This guide covers the practical approach, not the theoretical one. Real code, real tradeoffs, real decisions.

If you want to skip the scaffolding and generate a CQRS-ready Laravel application from a prompt, try Laracopilot free and see how much boilerplate you can eliminate in under 8 minutes.


What CQRS actually means in a Laravel context

CQRS is not an all-or-nothing architectural overhaul. It is a pattern you can introduce incrementally into an existing Laravel application without rewriting everything.

The core principle: reads and writes are different problems. Reads need to be fast, flexible, and often serve multiple display formats. Writes need to be consistent, validated, and often trigger side effects (events, notifications, cache invalidations). Forcing both through the same model creates tension.

In Laravel, a naive implementation looks like this: a single Eloquent model handles both reading (scope methods, relationship loading) and writing (mutations, business rule enforcement). This works up to a point. When your product table serves a JSON API, a Filament admin panel, a CSV export endpoint, and a Stripe webhook handler all at once, the model becomes a liability.

CQRS gives you two distinct paths: a Command side for mutations, and a Query side for reads.

The three-tier CQRS structure

In a Laravel application, I recommend a three-tier structure:

  1. Commands (write side): plain PHP objects that carry intent and data
  2. Command Handlers: classes that execute the business logic for one command
  3. Query objects: classes that encapsulate read logic and return data transfer objects (DTOs)

This keeps controllers thin, tests focused, and logic discoverable.


Setting up the command side

Let me start with a concrete example: processing an order in an e-commerce application.

Mini-story: how this saved Marcus's team 3 weeks of debugging

Marcus runs a Laravel agency in Berlin. His team had a placeOrder method on their Order model that handled stock checking, payment charging, notification sending, and inventory updating, all in sequence. When a race condition caused double-charging in early 2025, they spent three weeks finding the bug because the logic was spread across callbacks, observers, and the model itself. After restructuring to CQRS, each concern had one class. The race condition became obvious in 20 minutes.

Here is the command structure:

// app/Commands/PlaceOrderCommand.php
namespace App\Commands;

final class PlaceOrderCommand
{
    public function __construct(
        public readonly int $userId,
        public readonly array $items,
        public readonly string $shippingAddress,
        public readonly string $paymentMethodId,
    ) {}
}

This is a plain PHP object. No Eloquent, no dependencies. It carries the intent ("place an order") and the data needed to fulfil it.

Writing the command handler

The handler does the actual work:

// app/Handlers/PlaceOrderHandler.php
namespace App\Handlers;

use App\Commands\PlaceOrderCommand;
use App\Models\Order;
use App\Events\OrderPlaced;
use App\Exceptions\InsufficientStockException;

final class PlaceOrderHandler
{
    public function __construct(
        private readonly StockService $stockService,
        private readonly PaymentService $paymentService,
    ) {}

    public function handle(PlaceOrderCommand $command): Order
    {
        foreach ($command->items as $item) {
            if (!$this->stockService->hasStock($item['product_id'], $item['quantity'])) {
                throw new InsufficientStockException($item['product_id']);
            }
        }

        $payment = $this->paymentService->charge(
            $command->paymentMethodId,
            $this->calculateTotal($command->items)
        );

        $order = Order::create([
            'user_id' => $command->userId,
            'shipping_address' => $command->shippingAddress,
            'payment_id' => $payment->id,
            'status' => 'confirmed',
        ]);

        foreach ($command->items as $item) {
            $order->items()->create($item);
            $this->stockService->deduct($item['product_id'], $item['quantity']);
        }

        event(new OrderPlaced($order));

        return $order;
    }

    private function calculateTotal(array $items): int
    {
        return collect($items)->sum(fn($item) => $item['price'] * $item['quantity']);
    }
}

Notice what is not in this handler: HTTP request handling, JSON response formatting, controller logic. The handler does one thing: execute the PlaceOrder business logic.

Wiring it into the controller

// app/Http/Controllers/OrderController.php
public function store(PlaceOrderRequest $request, PlaceOrderHandler $handler): JsonResponse
{
    $command = new PlaceOrderCommand(
        userId: $request->user()->id,
        items: $request->validated('items'),
        shippingAddress: $request->validated('shipping_address'),
        paymentMethodId: $request->validated('payment_method_id'),
    );

    $order = $handler->handle($command);

    return response()->json(['order_id' => $order->id], 201);
}

The controller is now four lines of logic. It validates, creates the command, delegates to the handler, and returns a response. That is exactly what a controller should do.


Setting up the query side

Queries are simpler than commands in most cases. They fetch and transform data, with no side effects.

// app/Queries/GetUserOrdersQuery.php
namespace App\Queries;

use App\DTOs\OrderSummaryDTO;
use App\Models\Order;
use Illuminate\Support\Collection;

final class GetUserOrdersQuery
{
    public function execute(int $userId, int $perPage = 20): Collection
    {
        return Order::query()
            ->where('user_id', $userId)
            ->with(['items.product'])
            ->latest()
            ->paginate($perPage)
            ->through(fn($order) => OrderSummaryDTO::fromModel($order));
    }
}

And the accompanying DTO:

// app/DTOs/OrderSummaryDTO.php
namespace App\DTOs;

final class OrderSummaryDTO
{
    public function __construct(
        public readonly int $id,
        public readonly string $status,
        public readonly int $totalCents,
        public readonly int $itemCount,
        public readonly string $createdAt,
    ) {}

    public static function fromModel(Order $order): self
    {
        return new self(
            id: $order->id,
            status: $order->status,
            totalCents: $order->items->sum(fn($i) => $i->price_cents * $i->quantity),
            itemCount: $order->items->count(),
            createdAt: $order->created_at->toISOString(),
        );
    }
}

The query returns DTOs, not Eloquent models. This matters: the API response format is decoupled from your database schema. Rename a column, and the DTO absorbs the change. Your API stays stable.


Using a command bus for larger applications

Once you have more than a handful of commands, dispatching them manually from controllers gets repetitive. A command bus solves this.

Laravel ships with a built-in command bus via the Bus facade, but for CQRS I prefer a lightweight custom dispatcher:

// app/Bus/CommandBus.php
namespace App\Bus;

use Illuminate\Container\Container;

final class CommandBus
{
    public function __construct(private readonly Container $container) {}

    public function dispatch(object $command): mixed
    {
        $handlerClass = $this->resolveHandler($command);
        $handler = $this->container->make($handlerClass);

        return $handler->handle($command);
    }

    private function resolveHandler(object $command): string
    {
        $commandClass = get_class($command);
        $handlerClass = str_replace('Commands', 'Handlers', $commandClass) . 'Handler';

        if (!class_exists($handlerClass)) {
            throw new \RuntimeException("No handler found for {$commandClass}");
        }

        return $handlerClass;
    }
}

Register it as a singleton in a service provider, and your controllers become even simpler:

$order = $this->commandBus->dispatch(new PlaceOrderCommand(...));

Mini-story: Sarah's team cut controller complexity by 60%

Sarah leads engineering at a SaaS startup in Singapore. In February 2025, their Laracopilot-generated application had clean initial scaffolding, but three months of feature additions had bloated their controllers back to 200+ lines. After introducing a command bus and migrating 12 controllers to CQRS, the average controller dropped from 180 lines to 67. Code review cycles went from 45 minutes average to under 20.


Testing CQRS components in Laravel

CQRS makes testing significantly easier because each piece has one job.

Testing a command handler

// tests/Unit/Handlers/PlaceOrderHandlerTest.php
use App\Commands\PlaceOrderCommand;
use App\Handlers\PlaceOrderHandler;

it('places an order successfully', function () {
    $stockService = Mockery::mock(StockService::class);
    $stockService->shouldReceive('hasStock')->andReturn(true);
    $stockService->shouldReceive('deduct')->once();

    $paymentService = Mockery::mock(PaymentService::class);
    $paymentService->shouldReceive('charge')->andReturn(
        (object)['id' => 'pay_abc123']
    );

    $handler = new PlaceOrderHandler($stockService, $paymentService);

    $command = new PlaceOrderCommand(
        userId: 1,
        items: [['product_id' => 1, 'quantity' => 2, 'price' => 1000]],
        shippingAddress: '123 Main St',
        paymentMethodId: 'pm_test_123',
    );

    $order = $handler->handle($command);

    expect($order->status)->toBe('confirmed');
    expect($order->user_id)->toBe(1);
});

No HTTP, no seeded database, no factories. Just the handler and mocked dependencies. These tests run in milliseconds. They fail for exactly one reason: the handler logic is wrong.

Testing a query

it('returns order summaries for a user', function () {
    $user = User::factory()->create();
    Order::factory()->count(3)->for($user)->hasItems(2)->create();

    $query = new GetUserOrdersQuery();
    $results = $query->execute($user->id);

    expect($results)->toHaveCount(3);
    expect($results->first())->toBeInstanceOf(OrderSummaryDTO::class);
});

When CQRS makes sense (and when it does not)

CQRS adds overhead. More files, more indirection, more conventions to learn. Before implementing it, be honest about your application's needs.

CQRS makes sense when:

  • Your read and write loads are fundamentally different (heavy reads, occasional writes)
  • Business logic in commands is complex enough to warrant isolation
  • Multiple teams work on the same application and need clear ownership boundaries
  • You need independent scaling for read and write paths

CQRS adds unnecessary complexity when:

  • Your application is primarily CRUD with minimal business logic
  • Your team is small and moving fast on an MVP
  • Read and write patterns are simple and symmetric

For Laracopilot users: if you are generating a new Laravel SaaS with Laracopilot's AI builder, I recommend starting with the standard MVC scaffold and introducing CQRS incrementally as complexity grows. Premature architecture is a real cost.


Generating CQRS scaffolding with Laracopilot

One of the things I am most proud of at Laracopilot is how it handles architectural prompts. You can tell it to generate a CQRS-structured module and it produces the command, handler, query, and DTO files, all wired together, all following PSR-12 and Laravel conventions.

A prompt like: "Create a CQRS module for processing subscription renewals, including a RenewSubscriptionCommand, its handler, a GetSubscriptionQuery, and a SubscriptionDTO" returns production-ready code in under two minutes.

This is where building Laravel apps with AI changes the economics of clean architecture: the boilerplate cost drops to near zero, so there is no excuse to skip the pattern.


Laravel-specific CQRS considerations

A few Laravel-specific decisions worth knowing before you start:

Eloquent on the query side: Many CQRS guides say to avoid Eloquent on the read side and use raw queries or a read model instead. In practice, Eloquent with eager loading is fast enough for most Laravel applications. Start with Eloquent on the read side. Move to raw queries or a dedicated read database only when profiling shows a bottleneck.

Events on the command side: Laravel's event system pairs naturally with CQRS. Fire events from command handlers, not from controllers. OrderPlaced, PaymentFailed, SubscriptionRenewed are business events that belong in your command handler, not in your HTTP layer.

Filament and CQRS: If you are using Filament v3 for admin panels (which I recommend, and which Laracopilot generates natively), your Filament resources can call command handlers directly from their create() and save() lifecycle hooks. This keeps the admin UI and the business logic cleanly separated.

Mini-story: the Devlyn.ai client who reduced deployment incidents by 80%

A fintech startup came to Devlyn.ai in late 2024 with a Laravel monolith that had 900-line controllers, no tests, and three production incidents per month. Our senior engineers refactored the critical payment and KYC flows to CQRS over six weeks. Command handlers gave them isolated units to test. The test suite went from 0% to 74% coverage. Production incidents dropped from three per month to under one per quarter within 90 days of the refactor shipping.


Conclusion

Implementing CQRS in a Laravel application is not a big-bang rewrite. Start with one flow where the read and write complexity diverges. Extract a command and handler. Extract a query and DTO. See how the code feels.

The pattern pays for itself when your handlers become the test targets for your business logic, when your queries serve multiple consumers without breaking, and when new team members can find the business logic for any operation by looking at one class.

The single most important takeaway: CQRS works best when you treat it as a problem-solver, not a default. Introduce it where complexity demands it, then let it spread as the application grows.

If you want to generate a CQRS-structured Laravel application without writing all the scaffolding by hand, try Laracopilot free at laracopilot.com. It generates commands, handlers, queries, and DTOs natively, connected and following Laravel conventions.

For teams that need a senior engineer to implement or audit a CQRS refactor, Devlyn.ai has engineers who have done this across dozens of Laravel applications.


Alpesh Nakrani is VP of Growth at Laracopilot and Devlyn.ai. He writes about Laravel architecture, AI-powered development, and SaaS growth at alpeshnakrani.com.

© 2020 - 2026 Alpesh Nakrani