CQRS in Laravel Without a Framework: Commands, Handlers, and Read Models That Stay Lean
#laravel #cqrs #architecture #clean-code #php

CQRS in Laravel Without a Framework: Commands, Handlers, and Read Models That Stay Lean

1 min read Mohamed Said Mohamed Said

Why CQRS Without a Framework?

Most CQRS tutorials reach for hirethunk/verbs or spatie/laravel-event-sourcing immediately. Those are excellent packages, but they carry opinions about event stores, projectors, and snapshots that you may not need. If your goal is simply to separate write intent from read concerns, you can do it with plain PHP classes and Laravel's service container in an afternoon.

This article focuses on that narrower goal: a command bus, typed command objects, dedicated handlers, and Eloquent-backed read models — nothing more.


The Command Object

A command is a value object expressing intent. It carries validated input; it does not execute anything.

<?php

namespace App\Commands\Order;

final readonly class PlaceOrderCommand
{
    public function __construct(
        public readonly string $customerId,
        public readonly array  $lineItems,
        public readonly string $currencyCode = 'USD',
    ) {}
}

Using readonly classes (PHP 8.2+) prevents accidental mutation after construction.


The Command Handler

Handlers are single-responsibility classes. One command → one handler.

<?php

namespace App\Handlers\Order;

use App\Commands\Order\PlaceOrderCommand;
use App\Models\Order;
use App\Models\OrderLine;
use Illuminate\Support\Facades\DB;

final class PlaceOrderHandler
{
    public function handle(PlaceOrderCommand $command): Order
    {
        return DB::transaction(function () use ($command) {
            $order = Order::create([
                'customer_id'   => $command->customerId,
                'currency_code' => $command->currencyCode,
                'status'        => 'pending',
            ]);

            foreach ($command->lineItems as $item) {
                OrderLine::create([
                    'order_id'   => $order->id,
                    'product_id' => $item['product_id'],
                    'quantity'   => $item['quantity'],
                    'unit_price' => $item['unit_price'],
                ]);
            }

            return $order->refresh();
        });
    }
}

A Minimal Command Bus

Instead of a full-blown bus library, bind handlers in the service container and resolve them by convention.

<?php

namespace App\Bus;

use Illuminate\Contracts\Container\Container;

final class CommandBus
{
    private array $map = [];

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

    public function register(string $command, string $handler): void
    {
        $this->map[$command] = $handler;
    }

    public function dispatch(object $command): mixed
    {
        $handlerClass = $this->map[$command::class]
            ?? throw new \RuntimeException('No handler for ' . $command::class);

        return $this->container->make($handlerClass)->handle($command);
    }
}

Register mappings in a CommandBusServiceProvider:

$bus->register(PlaceOrderCommand::class, PlaceOrderHandler::class);

Because handlers are resolved through the container, constructor injection (repositories, mailers, etc.) works for free.


Read Models: Separate Queries from Writes

The read side should never touch the write model's business logic. Create dedicated query classes that return DTOs or simple arrays optimised for the UI.

<?php

namespace App\Queries\Order;

use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;

final class CustomerOrderSummaryQuery
{
    public function execute(string $customerId): Collection
    {
        return DB::table('orders')
            ->join('order_lines', 'orders.id', '=', 'order_lines.order_id')
            ->where('orders.customer_id', $customerId)
            ->selectRaw('
                orders.id,
                orders.status,
                orders.currency_code,
                SUM(order_lines.quantity * order_lines.unit_price) AS total
            ')
            ->groupBy('orders.id', 'orders.status', 'orders.currency_code')
            ->orderByDesc('orders.id')
            ->get();
    }
}

This query is free to use raw SQL, window functions, or any optimisation without polluting the Order Eloquent model.


Wiring It Into a Controller

public function store(PlaceOrderRequest $request, CommandBus $bus): JsonResponse
{
    $order = $bus->dispatch(new PlaceOrderCommand(
        customerId: $request->user()->id,
        lineItems:  $request->validated('items'),
    ));

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

public function index(Request $request, CustomerOrderSummaryQuery $query): JsonResponse
{
    return response()->json(
        $query->execute($request->user()->id)
    );
}

The controller stays thin. The command bus handles writes; the query class handles reads.


Testing Is Straightforward

it('places an order and persists line items', function () {
    $customer = User::factory()->create();

    $command = new PlaceOrderCommand(
        customerId: $customer->id,
        lineItems: [
            ['product_id' => 1, 'quantity' => 2, 'unit_price' => 1999],
        ],
    );

    $order = app(PlaceOrderHandler::class)->handle($command);

    expect($order->status)->toBe('pending')
        ->and($order->orderLines)->toHaveCount(1);
});

Handlers are plain classes — no HTTP layer, no mocking the bus.


Key Takeaways

  • Commands are value objects — immutable, no logic, just typed intent.
  • One handler per command keeps responsibilities explicit and testable in isolation.
  • A minimal command bus backed by the service container gives you DI without a library.
  • Read models are separate query classes — optimise SQL freely without touching domain models.
  • Controllers stay thin — dispatch a command or call a query, return a response.
  • You can adopt event sourcing later; this structure does not block it.

Found this useful?

Frequently Asked Questions

3 questions
Q01 Do I need event sourcing to use CQRS in Laravel?
No. CQRS simply separates write operations (commands) from read operations (queries). Event sourcing is an optional persistence strategy that pairs well with CQRS but is not required.
Q02 How does this differ from using Laravel's built-in Bus facade?
Laravel's Bus facade is designed for queued jobs. A custom command bus keeps commands synchronous and domain-focused, with explicit handler registration rather than convention-based discovery — giving you more control over the dispatch lifecycle.
Q03 Should read model queries use Eloquent or raw DB queries?
Either works. Raw DB queries via the query builder are often preferable for read models because they let you use aggregates, window functions, and custom projections without loading full Eloquent models and their overhead.

Continue reading

More Articles

View all