Index

A Domain Event Is a Fact, Not a Question

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:

  1. CreditPolicy answers whether placement may begin.
  2. Order::place() accepts or refuses the transition and records OrderPlaced.
  3. PlaceOrderHandler persists the changed order.
  4. 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_refused

There 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 choiceListener failure means
Inside the transactionthe order and local reactions roll back together
After committhe 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.