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.