I once watched a team where no one could give a straight answer to “what happens when an order is created?” In the codebase, eight separate listeners were subscribed to the OrderCreated event; you couldn’t read from any single place which one ran in which order, under which condition. The flow didn’t live in the code — it lived in people’s heads, and incompletely at that.

Event-driven architecture loosens coupling. This post is a reminder that something else loosens along with it: causality.

What events solve

Code that publishes an event doesn’t know who listens to it. OrderCreated is published; billing, notifications, and analytics each listen to it separately. The order code is unaware of all three.

The benefit is real: adding a new consumer happens without touching the producer. Independent development, independent deployment, fan-out. Parts that don’t know each other.

What events hide

The flip side of the same coin: as coupling loosens, the flow becomes invisible.

In a direct method call, the flow is the code — you go to the definition and read on. Not so with an event. To see what happens when OrderCreated is published, you have to find every listener by hand. The compiler won’t help you: a compiler verifies the contract of a method call, but no one verifies the contract of an event.

The line from the modular monolith post applies here too — like a network contract, an event contract sits in the compiler’s blind spot.

Don’t choose it before paying: two prerequisites

Before moving to an event-driven architecture, you pay two costs up front.

Schema discipline. An event is a contract. When the payload of OrderCreated changes, the eight places listening to it won’t know — not until it breaks in production. Events must be versioned, fields must be added in a backward-compatible way, payloads must be validated against a schema. Without this discipline, loose coupling means “silent breakage.”

Traceability. To be able to see a request travel through the system, every event must carry a correlation ID:

event(new OrderCreated(
    orderId: $order->id,
    correlationId: request()->header('X-Correlation-Id') ?? (string) Str::uuid(),
));

If this ID isn’t carried into the logs and into subsequent events, then in a multi-listener flow there is no answer to “why did this job run?” Tracing isn’t an optional add-on to event-driven; it’s a prerequisite.

In Laravel: in-process events ≠ event-driven architecture

Laravel’s event()/listener mechanism, when it runs synchronously, is really an organized method call — same process, same transaction, same stack trace. This is safe and traceable; use it freely.

The difficulties event-driven architecture brings start when an event crosses a boundary: into a queue, into a message broker, into another process. The moment you add ShouldQueue to a listener and send the event to RabbitMQ, the schema and tracing debt kicks in. Don’t conflate the two: using in-process listeners does not move you into “event-driven architecture.”

When to move to event-driven

Actually carrying an event across a boundary is justified in these cases:

  • There’s a real fan-out. A single event is listened to by many independent consumers that shouldn’t know about each other.
  • The consumer must be asynchronous. The work shouldn’t keep the producer waiting for a response; “accepted” is enough.
  • The consumers must scale or be distributed independently. This also brings us to the microservices decision — the same measured-signal threshold.

Without these, a direct service call is both more readable and safer. An event with a single listener is just a method call in disguise — and a harder one to trace, at that.


Event-driven architecture loosens coupling; in doing so, it loosens causality too. If you want the first, pay the bill for the second — schema and tracing — up front.

Loose coupling isn’t free; you pay for it with invisible flow.