Master Laravel CQRS and eventual consistency using versioned state snapshots. Learn how to keep your read models deterministic and reliable at scale.
Last month, I spent three days debugging a race condition where our read models were showing stale data for nearly 400ms after a critical transaction. When you move away from a monolithic CRUD approach into a decoupled architecture, you quickly realize that the gap between a command execution and a projection update is where your system’s reliability goes to die.
In a standard Laravel CQRS setup, your write model emits domain events, which a queue worker eventually consumes to update the read model. The problem is that the user often triggers a read immediately after the command returns. If the Domain Events haven't been processed yet, the UI reflects the "old" state, leading to user confusion and support tickets.
We initially tried forcing synchronous projections for critical paths. That was a mistake. It spiked our request latency and made the write model fragile. If the projection failed, the entire user request failed. We needed a way to guarantee consistency without sacrificing the performance benefits of asynchronous processing.
The solution isn't to make everything synchronous, but to make the read model aware of the "version" it currently holds compared to the write model. By attaching a version number (or a sequence ID) to your aggregates, you can track the progress of your projections.
When you execute a command, you increment the version on the aggregate root. That version travels with the domain event. When your projection handler receives the event, it updates the read model and stores the latest version ID.
PHP#6A9955">// Inside your Aggregate Root public function apply(OrderPlaced $event): void { $this->version++; $this->state = $event->state; } #6A9955">// Inside your Projection Handler public function handle(OrderPlaced $event): void { DB::table('order_read_models')->updateOrInsert( ['id' => $event->orderId], [ 'data' => json_encode($event->payload), 'version' => $event->version, 'updated_at' => now(), ] ); }
To ensure the client sees consistent data, you can implement a "version-check" header in your API responses. When the write model processes a request, it returns the new version number. The frontend stores this. On subsequent reads, the client sends this version in the header.
If the read model’s version is lower than the requested version, you have two choices:
Using Database caching: Implementing Redis Write-Through for Consistency can help here. You can store the "latest version" for a specific aggregate in Redis. Your read model API can check this key to decide if it needs to serve data or trigger a "please wait" state.
By tracking versioned state snapshots, you decouple the eventual nature of the background processing from the immediate needs of the user. You aren't forcing the write model to wait; you're providing the read model with the context it needs to report its own readiness.
I’ve found that keeping a last_processed_version column on the read model is essentially free in terms of performance. It adds about 2-3 bytes to your row size but provides the observability you need to detect when your queues are falling behind. When combined with Laravel Read-Write Splitting: Deterministic Connection Routing Guide, you can ensure that your read models are always pulling from a consistent replica without hitting the primary database.
This approach isn't a silver bullet. You’re essentially introducing state management complexity to solve a concurrency problem. If your aggregate root generates events at a massive scale, your version increments become a bottleneck on the primary write database.
I’m still experimenting with how to handle "gaps" in versions. If a projection fails and skips a version, you need a recovery strategy—usually a replay of events from the event store. It’s not trivial, but it’s the price of admission for a truly decoupled, scalable system. If you’re just building a standard CRUD app, don't over-engineer this. But if your system relies on Laravel Workflow: Architecting Asynchronous State Machines for Reliability to handle complex business processes, versioning is non-negotiable.
What I'd do differently next time? I'd start with a simple sequence counter in the database rather than trying to build a full-blown event store from day one. It’s easier to debug and gets you 90% of the way there.
Master Laravel Queues and the Fork-Join pattern. Learn to implement parallel processing with Redis Lua scripting for atomic task decomposition and scaling.