At its useful core, CQRS is one rule: a write protects business rules and changes state; a read returns the shape a caller needs. Laravel already allows that split. The mistake is assuming every command needs a bus, every query a second database, and every update an event stream.
This article uses an order operations screen as the running example. Staff can place an order, while an internal dashboard lists recent orders, totals by state, and the customer name. The write path needs a transaction and a stock invariant. The dashboard needs neither full Order models nor a chain of lazy-loaded relationships. That asymmetry is the reason for CQRS-lite.
Start with a boring, transactional write action
The write model owns the facts that must remain true: an order cannot contain an unavailable product, its total is calculated from the price at purchase time, and it is either fully recorded or not recorded at all. Put those decisions in one action with one public intention.
<?php
namespace App\Actions\Orders;
use App\Models\Order;
use App\Models\Product;
use App\Models\User;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
final class PlaceOrder
{
/** @param array<int, array{product_id: int, quantity: int}> $lines */
public function handle(User $customer, array $lines): Order
{
return DB::transaction(function () use ($customer, $lines): Order {
$order = Order::query()->create([
'user_id' => $customer->id,
'status' => 'pending',
'subtotal_cents' => 0,
]);
$subtotalCents = 0;
foreach ($lines as $line) {
$product = Product::query()->lockForUpdate()->findOrFail($line['product_id']);
if ($product->stock < $line['quantity']) {
throw ValidationException::withMessages([
'lines' => "{$product->name} is no longer available in that quantity.",
]);
}
$product->decrement('stock', $line['quantity']);
$order->lines()->create([
'product_id' => $product->id,
'product_name' => $product->name,
'unit_price_cents' => $product->price_cents,
'quantity' => $line['quantity'],
]);
$subtotalCents += $product->price_cents * $line['quantity'];
}
$order->update(['subtotal_cents' => $subtotalCents]);
return $order->refresh();
}, attempts: 3);
}
}
There is no “write DTO” merely for ceremony. A form request can validate the incoming shape, then pass its validated lines to this action. The transaction deliberately covers stock, lines, and the order total. Moving the calculation to an asynchronous projection would let a successful checkout briefly show a total of zero; moving stock deduction to a listener makes overselling much easier under concurrent requests.
The controller stays unsurprising:
public function store(PlaceOrderRequest $request, PlaceOrder $placeOrder): RedirectResponse
{
$order = $placeOrder->handle($request->user(), $request->validated('lines'));
return to_route('orders.show', $order)->with('success', 'Order placed.');
}
A command here is an application operation, not a package to install. Name it in the business's words (PlaceOrder) and test it where its invariants live.
Make the first read model a query class, not a duplicate domain
The dashboard is a different question: “show the most recent operational facts.” Returning Order::with('user')->latest()->paginate() is tempting, but it couples a screen to the write model and often quietly loads fields it does not need. A dedicated query makes the response shape, join, sorting, and pagination visible.
<?php
namespace App\Queries\Orders;
use Illuminate\Contracts\Pagination\CursorPaginator;
use Illuminate\Database\Query\Builder;
use Illuminate\Support\Facades\DB;
final class RecentOrderDashboard
{
/** @return CursorPaginator<object> */
public function paginate(?string $cursor, int $perPage = 30): CursorPaginator
{
return $this->baseQuery()
->orderByDesc('orders.created_at')
->orderByDesc('orders.id')
->cursorPaginate(perPage: $perPage, cursorName: 'cursor', cursor: $cursor);
}
private function baseQuery(): Builder
{
return DB::table('orders')
->join('users', 'users.id', '=', 'orders.user_id')
->select([
'orders.id', 'orders.status', 'orders.subtotal_cents',
'orders.created_at', 'users.name as customer_name',
]);
}
}
The created_at, id ordering has to be deterministic for a cursor to mean anything. created_at alone is not enough when two rows share a timestamp; adding id prevents duplicate or skipped results while new orders arrive. Cursor pagination suits an append-oriented operations feed. It is a poor fit for a report where users jump to page 47 or sort by ten columns; there, ordinary paginate() is the honest answer.
The controller maps query rows into a deliberately small response instead of leaking a database object through the application:
public function index(Request $request, RecentOrderDashboard $dashboard): InertiaResponse
{
$orders = $dashboard->paginate($request->string('cursor')->toString());
return Inertia::render('Orders/Index', [
'orders' => [
'data' => collect($orders->items())->map(fn (object $order): array => [
'id' => $order->id,
'customerName' => $order->customer_name,
'status' => $order->status,
'subtotalCents' => $order->subtotal_cents,
'placedAt' => $order->created_at,
]),
'nextCursor' => $orders->nextCursor()?->encode(),
],
]);
}
Add a projection only after the query has a measured problem
A query class is already a read model and duplicates no data. A projection pays off when a join and aggregation are repeatedly expensive, when an external source needs a local screen-friendly shape, or when a report needs a historical snapshot that the write tables no longer represent.
A dashboard card with daily revenue by status should not aggregate every order line on each request. A projection table makes the read cheap and predictable:
Schema::create('daily_order_metrics', function (Blueprint $table): void {
$table->date('metric_date');
$table->string('status');
$table->unsignedBigInteger('orders_count')->default(0);
$table->unsignedBigInteger('revenue_cents')->default(0);
$table->timestamp('projected_at');
$table->primary(['metric_date', 'status']);
});
Use an event to request the projection update, but dispatching an event is not a distributed transaction. The order commits first; the queued listener may run seconds later, retry, or fail. That is acceptable only if the screen says so.
final class UpdateDailyOrderMetrics implements ShouldQueue
{
public function handle(OrderPlaced $event): void
{
$order = $event->order;
DB::table('daily_order_metrics')->upsert([
[
'metric_date' => $order->created_at->toDateString(),
'status' => $order->status,
'orders_count' => 1,
'revenue_cents' => $order->subtotal_cents,
'projected_at' => now(),
],
], ['metric_date', 'status'], [
'orders_count' => DB::raw('orders_count + 1'),
'revenue_cents' => DB::raw('revenue_cents + '.(int) $order->subtotal_cents),
'projected_at' => now(),
]);
}
}
The increment is a raw expression, so it refers to the table column directly. Do not reach for VALUES(revenue_cents) inside it: MySQL deprecated that function in 8.0.20 in favour of row aliases (INSERT ... AS new ON DUPLICATE KEY UPDATE col = new.col), and the integer cast above keeps the expression valid either way. More important: make the listener idempotent. A queue can retry after the row was written but before the job was acknowledged. Store a processed event ID, or rebuild a date's metric from source rows, rather than incrementing blindly and hoping every job runs exactly once.
Freshness is part of the read contract
Eventual consistency changes what a user may safely infer, so it belongs in the UI, not in a backlog ticket. A checkout confirmation should read the newly committed order from the write model. A revenue dashboard can show Updated at 10:42 and say it refreshes within a minute. A fraud hold queue cannot casually lag if staff use it to release shipments.
Give the projection a visible watermark:
$metrics = DB::table('daily_order_metrics')
->selectRaw('SUM(orders_count) as orders_count, SUM(revenue_cents) as revenue_cents')
->first();
$freshness = DB::table('daily_order_metrics')->max('projected_at');
Do not label data “live” when a worker is behind. Alert on queue age and failed projection jobs. If a specific request needs read-your-writes consistency, return the command result or query the primary write database for that request. Sending it to a replica immediately after a write simply moves the surprise to replication lag.
Backfill projections as a product operation
Every projection needs a repair path before it is trusted. Events may have been dropped before a listener was deployed; its logic may change; a production bug may need correction. A command rebuilds a bounded period from the authoritative order rows, aggregating them in the database instead of counting row by row.
use App\Models\Order;
use Illuminate\Console\Command;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
final class RebuildDailyOrderMetrics extends Command
{
protected $signature = 'metrics:rebuild-orders {--from=} {--to=}';
public function handle(): int
{
$from = Carbon::parse($this->option('from'))->startOfDay();
$to = Carbon::parse($this->option('to'))->endOfDay();
DB::transaction(function () use ($from, $to): void {
DB::table('daily_order_metrics')
->whereBetween('metric_date', [$from->toDateString(), $to->toDateString()])
->delete();
$rows = Order::query()
->whereBetween('created_at', [$from, $to])
->selectRaw('DATE(created_at) as metric_date, status, COUNT(*) as orders_count, SUM(subtotal_cents) as revenue_cents')
->groupByRaw('DATE(created_at), status')
->toBase()
->get()
->map(fn (object $row): array => [
'metric_date' => $row->metric_date,
'status' => $row->status,
'orders_count' => $row->orders_count,
'revenue_cents' => $row->revenue_cents,
'projected_at' => now(),
]);
foreach ($rows->chunk(500) as $chunk) {
DB::table('daily_order_metrics')->insert($chunk->values()->all());
}
});
return self::SUCCESS;
}
}
This illustrative version is not safe to run alongside the live incremental listener: it can race and double-count. In production, pause the relevant consumer, rebuild into a versioned table, validate totals against orders, then atomically switch the reader; or record a high-water mark and replay events after that point. Backfills are operational work, so document the lock, duration, rollback, and owner.
Test commands, queries, and projection repair separately
Test contracts, not whether Laravel can resolve a class from the container.
it('does not oversell a product', function (): void {
$product = Product::factory()->create(['stock' => 1, 'price_cents' => 2_500]);
$customer = User::factory()->create();
app(PlaceOrder::class)->handle($customer, [['product_id' => $product->id, 'quantity' => 1]]);
expect(fn () => app(PlaceOrder::class)->handle($customer, [[
'product_id' => $product->id, 'quantity' => 1,
]]))->toThrow(ValidationException::class);
expect($product->fresh()->stock)->toBe(0);
});
it('uses an id tie breaker for a stable dashboard cursor', function (): void {
Order::factory()->count(2)->create(['created_at' => now(), 'user_id' => User::factory()]);
$firstPage = app(RecentOrderDashboard::class)->paginate(null, 1);
$secondPage = app(RecentOrderDashboard::class)->paginate($firstPage->nextCursor()?->encode(), 1);
expect($firstPage->items()[0]->id)->not->toBe($secondPage->items()[0]->id);
});
Add a projection test with a duplicate event and assert that its total does not increase twice. Add an integration test for a rebuild with a fixed data set. Those tests force the team to choose an idempotency strategy rather than discovering one during a retry storm.
When CQRS-lite is the wrong tool
Do not split a simple CRUD form merely because “commands and queries” sound architectural. If one controller validates a profile, updates one row, and renders it back, a normal Eloquent model is clear and fast. Do not build a projection when an index, an eager-loaded relation, a constrained select, or a cached aggregate solves the measured problem. Do not introduce a read replica to cure an N+1 query.
Event sourcing is a separate decision: it keeps the domain event history as the source of truth and demands replay, schema evolution and stronger operational discipline. CQRS-lite can use ordinary relational write tables and a few purpose-built read paths. A database view is another middle ground when the derived shape is pure SQL and must always be current; it avoids staleness but not the cost of calculation.