Events are useful for separating responsibilities across a system.
But the fact that an event can be introduced does not mean an operation should be split into two parts.
Sometimes the second change is not a reaction to the first. It is a mandatory part of the same operation.
In that case, the state between the two changes may be invalid and should never become visible.
The main question I ask is: does the system remain correct if the second action happens later?
If not, one transaction is often a better boundary than an asynchronous event.
I Start by Looking at the Intermediate State
Step A | vintermediate state | vStep B Question: can the system remainbetween A and Band still be correct?This is the simplest test for me.
If the system is already fully valid after the first change, the second action may be an independent reaction.
If the data violates an invariant until the second change happens, splitting the two creates a dangerous gap.
An Event Creates a Gap Between Two Changes
Transaction 1 change Acommit | vevent | vqueue | vhandler | vTransaction 2Milliseconds, seconds, or much longer may pass between the two transactions.
The handler may be delayed, retried, or temporarily fail.
If the state after the first commit is already valid, that can be perfectly fine.
But if that state should never exist without the second change, the design moves a consistency problem into the queue.
One Transaction Keeps the Invalid Intermediate State Invisible
DB::transaction(function () use ( $firstId, $secondId,): void { $first = FirstRecord::query() ->whereKey($firstId) ->lockForUpdate() ->firstOrFail(); $second = SecondRecord::query() ->whereKey($secondId) ->lockForUpdate() ->firstOrFail(); $first->applyChange(); $second->applyRelatedChange();In this version, both changes have one completion point.
Either both become persistent or neither is visible as a completed operation.
An intermediate state may exist during execution, but it does not become a separate committed system state.
An Event Is Not a Replacement for Atomicity
An event is good at answering "who else wants to react to an already completed fact?".
A transaction answers a different question: "which changes must become visible together?".
These mechanisms do not compete and do not replace each other.
The problem begins when an event is used not to react to a fact, but to finish creating the fact itself.
I Imagine the Second Part Never Runs
Step A completed Step B did not run and neverwill run Is the systemstill valid? YES-> an event may be fine I deliberately test a stronger case than a short delay: the second action never happens at all.
If the data is still valid, asynchronous reaction may be acceptable.
If the system remains in a state that must later be repaired, the second action is probably not just an event reaction.
Other Requests May Observe the Intermediate State
Request A change Acommit | |--------- gap --------| | change B Request B read | vsees stateThis matters beyond the queue itself.
Another HTTP request, background job, or report may read the data exactly between the two changes.
If that state is invalid, it can already influence other system decisions.
A single transaction prevents that state from becoming a separate committed version of the data.
Splitting with an Event Introduces a Separate Recovery Problem
Once the second part runs separately, several new questions appear.
- What happens if the handler fails?
- How many times should it retry?
- Is retrying safe?
- How do we find half-completed operations?
- Who owns recovery?
Sometimes that complexity is justified. I do not introduce it when one local transaction naturally protects the rule.
One Transaction Is Not Always the Answer Either
This principle does not mean everything should run synchronously.
If the second action is a notification, analytics update, projection refresh, or another reaction that is not required for the main state to be valid, an event may be better.
A normal local database transaction also cannot atomically control several independent external systems.
I choose one transaction not because synchronous code is inherently better, but because the data model sometimes requires shared atomicity.
One Transaction Is Especially Natural Within One Storage Boundary
The simplest case is when both related changes live in the same database.
If they jointly protect one invariant, the database already provides a mechanism for committing them atomically.
In that situation, inserting an event between two local writes can add complexity to something a transaction solves directly.
Concurrent Changes May Require Locking as Well as a Transaction
A transaction alone does not automatically guarantee the ordering needed between concurrent requests.
If the decision depends on current record state, I re-read it inside the transaction and use lockForUpdate() when needed.
The transaction defines the atomic boundary, while locking can protect the critical decision from concurrent modification.
An Event May Still Be Useful After the Atomic Change Completes
DB::transaction(function () use ($id): void { $record = Record::query() ->whereKey($id) ->lockForUpdate() ->firstOrFail(); $record->applyPrimaryChange(); $record->applyRequiredRelatedChange(); $record->save();}); event( new OperationCompleted($id),);Choosing a transaction does not mean rejecting events.
An event may be exactly right after both mandatory changes have been committed successfully.
At that point it describes a completed fact instead of trying to finish an incomplete operation.
In real code I also respect the commit boundary so the event does not announce a fact that may still roll back.
My Simple Choice Between a Transaction and an Event
Is the second change requiredfor the first one to be valid? YES | vCan both be committedatomically in one storage boundary? YES | vone transaction Is the main state already validWhat I Check Before Splitting an Operation with an Event
Before making this decision, I ask several questions:
- Is the state after the first change valid without the second?
- What happens if the second action never runs?
- Can another request observe an invalid intermediate state?
- Do both changes protect one invariant together?
- Do both changes live inside one transactional storage boundary?
- Is locking required because of concurrent access?
- Does an event introduce separate retry and recovery complexity?
- Can the event instead be published after the atomic change is complete?
I Complete the Invariant Before Separating Reactions
Events are very useful when the system already has a completed fact and several independent reactions to it.
But when two changes together form one valid operation, I do not split them merely to reduce coupling between components.
An invalid intermediate state, handler retries, and recovery mechanisms may create a much more complicated system than one local transaction.
I therefore define the atomic boundary of the data rule first.
Only after the system reaches a valid completed state do I decide which later reactions can safely move to events.
If the system should never exist between two changes, I try not to create that gap in the architecture.
An event is good at reacting to a completed fact. It should not be the mandatory second half of a fact that is not complete yet.



