The Problem With Querying Your Write Model
Every sufficiently complex Laravel app eventually develops the same symptom: a single Eloquent model doing too much. It holds business logic, fires observers, and is also the thing you query for reports, dashboards, and API responses. The joins pile up, scopes multiply, and EXPLAIN starts returning rows you'd rather not see.
Full event sourcing solves this cleanly—but it's a significant architectural commitment. You don't always need it. What you often do need is a dedicated read model: a denormalised, query-optimised table (or set of tables) that is kept in sync with your write side through lightweight projectors.
This article shows you how to build that pattern in plain Laravel without pulling in Spatie's event-sourcing package or rebuilding your entire domain.
Anatomy of a Lightweight Projector
A projector is just a class that listens to domain events and updates a read model. In Laravel, that maps naturally to event listeners.
// app/Events/OrderPlaced.php
final class OrderPlaced
{
public function __construct(
public readonly Order $order,
) {}
}
// app/Projectors/OrderSummaryProjector.php
final class OrderSummaryProjector
{
public function handle(OrderPlaced $event): void
{
OrderSummary::create([
'order_id' => $event->order->id,
'customer_id' => $event->order->customer_id,
'total_cents' => $event->order->total_cents,
'status' => $event->order->status->value,
'placed_at' => $event->order->created_at,
]);
}
}
Register it in EventServiceProvider (or the #[AsEventListener] attribute in Laravel 11+):
protected $listen = [
OrderPlaced::class => [
OrderSummaryProjector::class,
],
];
The order_summaries table is your read model. It has exactly the columns your dashboard query needs—no joins, no computed attributes, no withCount.
Designing the Read Model Table
The read model schema should be shaped by the query, not the domain entity.
Schema::create('order_summaries', function (Blueprint $table) {
$table->id();
$table->unsignedBigInteger('order_id')->unique();
$table->unsignedBigInteger('customer_id')->index();
$table->unsignedBigInteger('total_cents');
$table->string('status', 32)->index();
$table->timestamp('placed_at')->index();
$table->timestamps();
});
Notice there's no foreign key constraint to orders. The read model is eventually consistent by design; referential integrity is the write side's concern.
Handling Updates and Deletions
Projectors must handle the full lifecycle:
public function handleStatusChanged(OrderStatusChanged $event): void
{
OrderSummary::where('order_id', $event->orderId)
->update(['status' => $event->newStatus->value]);
}
public function handleCancelled(OrderCancelled $event): void
{
OrderSummary::where('order_id', $event->orderId)->delete();
}
Group all projector methods in one class per read model. This keeps the projection logic co-located and easy to reason about.
Rebuilding a Projection
One of the underrated benefits of this pattern is that you can rebuild a read model from scratch by replaying your write-side data. Create an Artisan command:
final class RebuildOrderSummaries extends Command
{
protected $signature = 'projections:rebuild-order-summaries';
public function handle(): void
{
OrderSummary::truncate();
Order::with('customer')->lazyById(500)->each(function (Order $order) {
OrderSummary::create([
'order_id' => $order->id,
'customer_id' => $order->customer_id,
'total_cents' => $order->total_cents,
'status' => $order->status->value,
'placed_at' => $order->created_at,
]);
});
$this->info('Done.');
}
}
Using lazyById keeps memory flat regardless of table size.
Querying the Read Model
Your controller or query class now hits a single, indexed table:
final class OrderDashboardQuery
{
public function execute(int $customerId): Collection
{
return OrderSummary::where('customer_id', $customerId)
->where('status', '!=', 'cancelled')
->orderByDesc('placed_at')
->limit(50)
->get();
}
}
No joins. No with(). No N+1 risk. The query plan is trivially predictable.
Key Takeaways
- Read models decouple query shape from domain shape—design them for the consumer, not the entity.
- Projectors are just event listeners; no new infrastructure required.
- Eventual consistency is acceptable for most dashboard and reporting use cases.
- Rebuild commands give you a safety net when projection logic changes.
lazyById+ chunked iteration keeps rebuild memory usage flat at any scale.- Start with one read model for your most painful query; you don't need to project everything.