An order cannot be placed until a credit policy accepts it. The handler needs that answer before it can continue:
if (!$this->credit->allows($customerId, $amountCents)) {
throw new CreditRefused();
}
$order->place($customerId, $amountCents, $placedOn);Turn that call into a CheckCredit event and the question does not disappear.
It becomes harder to see. The handler still needs an answer, but an event
dispatcher normally gives it a list of listeners, not a typed decision. Now
the code needs a mutable payload, stopped propagation, or some other private
convention to recover the response.
OrderPlaced is different. It does not ask whether the order may be placed.
It says the order has accepted that transition. An activity ledger and a
daily counter may react to the fact, but neither gets to decide whether it is
true.
That distinction is a useful test for event-driven code: questions need visible answers; domain events describe accepted facts.
Four moments, not one dispatch call
“The application dispatches an event” compresses several decisions into one sentence. The reproducible Laravel fixture for this article keeps them separate:
CreditPolicyanswers whether placement may begin.Order::place()accepts or refuses the transition and recordsOrderPlaced.PlaceOrderHandlerpersists the changed order.- Application-owned subscribers observe the event inside the transaction or after its commit.
The order records the event after changing its state:
public function place(
string $customerId,
int $amountCents,
string $placedOn,
): void {
if ($this->status !== 'draft') {
throw new OrderTransitionRefused();
}
$this->status = 'placed';
$this->customerId = $customerId;
$this->amountCents = $amountCents;
$this->placedOn = $placedOn;
$this->recordedEvents[] = new OrderPlaced(/* ... */);
}A mutation records OrderPlaced before the status check. The aggregate
refusal test then finds an event for a transition which never happened. That
failure is more informative than a naming rule: past tense is only honest
when the fact is recorded after the boundary which can accept it.
The event is also not an Eloquent saved event with a more impressive name.
saved reports something about the persistence mechanism. OrderPlaced
reports a domain transition. Saving an already placed order may emit the
former without making the latter true again.
A Valid Command Can Still Be Refused examines the authority which accepts the transition. The event belongs after that decision, not beside the request which initiated it.
A required answer should remain at the call site
In the fixture, refusing credit produces this complete trace:
credit_policy_called
credit_refusedThere is no order row, recorded event, activity entry, or daily count. The handler can see the boolean answer and owns the refusal path.
The CheckCredit mutation replaces that dependency with an event-shaped
message and ignores the listener’s refusal. Its named assertion fails because
the order is placed anyway. Laravel can dispatch the object perfectly; the
collaboration is still wrong for the decision being made.
Prefer a direct dependency when any of these are true:
- the caller requires an answer before it may continue;
- one consequence is required to complete the use case;
- ordering between operations is part of the behavior; or
- the caller must handle a typed result or failure.
This is not an argument that indirect collaboration is always obscure. It is a narrower inference: a direct call makes this required answer inspectable in the constructor, return type, and control flow. The fixture proves what the event mutation hides. It does not measure how quickly a developer will understand either version.
A fact can have independent observers
Once placement succeeds, the example has two reactions:
$events->listen(
OrderPlaced::class,
[RecordOrderActivity::class, 'handle'],
);
$events->listen(
OrderPlaced::class,
[CountDailyOrders::class, 'handle'],
);Automatic event discovery is disabled. The committed provider is the subscriber map, and a generated inventory verifies that these are the only two listeners. Removing either registration fails an independent assertion.
That visibility matters because an event moves dependencies away from the publishing call site. It does not remove them. A reader should still be able to answer three questions without searching for framework magic:
- Which code records the fact?
- Which application code publishes it?
- Which complete set of subscribers reacts to it?
The empty-subscriber control sharpens the boundary. With no listeners, the order is still placed and persisted. The activity and daily-count tables stay empty. That is what makes the two handlers reactions rather than hidden steps required to complete placement.
If removing a listener makes the use case false, that listener may be a displaced required call. The fixture includes that mutation too: persistence is moved behind an unlisted event listener, and the handler returns without saving the order. Indirection has concealed an obligation rather than separated an observer.
Transaction timing changes the failure contract
Laravel’s event documentation describes ordinary listeners as synchronous. Where publication occurs therefore decides which transaction, if any, includes their database work.
The fixture first saves the order and publishes OrderPlaced inside one
database transaction. RecordOrderActivity succeeds; then
CountDailyOrders throws a controlled exception. The retained database state
is empty:
orders=[]
order_activity=[]
daily_order_counts=[]
listener_failures=[]Laravel’s transaction documentation specifies that an exception inside its transaction closure causes rollback and is rethrown. In this fixture, that rolls back the order and every listener write. This gives the operation one local atomic boundary, but it also means a reporting reaction can prevent order placement. That may be correct when every write belongs to the same invariant. It is a poor default when the reactions have different failure and repair needs.
The second variant saves the order in the transaction and registers
publication with DB::afterCommit(). Laravel runs that callback after the
root transaction commits. The same listener failure now leaves a different
state:
orders=[order-1: placed]
order_activity=[order-placed-order-1]
daily_order_counts=[]
listener_failures=[CountDailyOrders: controlled_failure]The fact is durable and one reaction is missing. The exception is visible, but rollback can no longer undo the placed order. The application now needs an honest way to detect and repair incomplete reactions.
Neither timing is supplied by the name OrderPlaced. Choose it from the
failure contract:
| Publication choice | Listener failure means |
|---|---|
| Inside the transaction | the order and local reactions roll back together |
| After commit | the order remains placed; failed reactions need repair |
The fixture proves these SQLite outcomes for synchronous, in-process listeners. It does not prove production scheduling, cross-service delivery, or durability after a process crash.
Delivery can repeat even when the fact did not
Publishing the same OrderPlaced object twice produces six trace entries:
both subscribers run for both dispatches. The activity listener stores the
event ID behind a uniqueness guard, so it leaves one activity row. The daily
counter deliberately has no duplicate guard, so its count becomes two.
The event object did not grant exactly-once handling. Each reaction had to define what the event ID meant and whether repeated observation was safe.
That concern is easy to dismiss for an in-process dispatcher. It becomes unavoidable when publication is retried, queued, or relayed across a process boundary. A Queued Command Changes the Contract follows the same ambiguity through retries and terminal failure.
An after-commit callback is not a transactional outbox. If a committed fact must eventually reach another process, use a durable record written with the domain change and accept the resulting relay, retention, monitoring, deduplication, and repair work. An in-memory callback cannot close the gap between commit and publication.
Sometimes two calls are the clearer design
An event dispatcher is not the only way to handle two reactions. The application can call two named services after persistence:
$orders->save($order);
$activity->recordPlacement($order);
$dailyOrders->incrementFor($order);That sequence may be preferable when the set is small, order is intentional, and the handler owns both consequences. Its transaction and failure path are visible in one place.
Reverse toward an event when OrderPlaced is the stable application fact and
the reactions can be added or removed without changing whether placement
succeeded. Keep an explicit subscriber inventory, decide publication timing,
and give each reaction a duplicate policy.
Reverse toward direct orchestration when the reactions are really ordered steps of one procedure. Reverse toward an outbox when observation must survive a process failure. Reverse toward no event at all when nobody needs the fact.
The choice is not “coupled” versus “decoupled.” Every version has a graph of dependencies and failures. The useful question is whether that graph contains a required answer, an accepted fact, or a delivery promise. A direct call answers the first. A domain event names the second. Reliable infrastructure must earn the third.