Event-Driven Architecture: A Practical Introduction

A practical intro to event-driven architecture: events vs commands, brokers, choreography vs orchestration, and the real costs you'll hit.

Jakub Owsianka
Jakub Owsianka
PHP Developer, Programming Enthusiast
11 min read
Event-Driven Architecture: A Practical Introduction

Most systems start as one request, one response, one database write. That works until the day a single user action needs to trigger five other things: send an email, update a search index, notify a billing service, bump a loyalty counter, and log something for the analytics team. Cram all of that into one controller and you get a slow, brittle endpoint that breaks in five ways at once. Event-driven architecture is one answer to that mess, and this post is a practical introduction to how it actually works: the parts that help, and the parts that will bite you.

I'll keep this grounded. No "unlock the power of" language, no promises that events fix everything. Just the mental model, the moving pieces, and the trade-offs I've run into building and debugging these systems.

What an event actually is

An event is a fact about something that already happened. Past tense, done, immutable. OrderPlaced, PaymentCaptured, UserRegistered. The thing occurred; you're just announcing it.

This is the distinction people trip over most, so it's worth slowing down:

  • A command expresses intent: "do this." PlaceOrder, SendEmail, ChargeCard. It's directed at a specific handler and it can be rejected.
  • An event reports history: "this happened." Nobody can reject it. It's already true. Zero or many parties can react, or none at all.

Why does the grammar matter? Because it shapes coupling. When you send a command, the sender knows who should act and expects a result. When you publish an event, the publisher doesn't know or care who's listening. That "doesn't care" is the whole point of the style, and also the source of most of its difficulty.

Producers, consumers, and the thing in the middle

Three roles show up in every event-driven system:

  • Producers (publishers) emit events when something noteworthy happens.
  • Consumers (subscribers) react to events they care about.
  • A transport carries events from one to the other so producers and consumers never call each other directly.

That transport is where the design decisions live. A few common shapes:

  • Message queue (RabbitMQ, Amazon SQS, Laravel's own queue): a message goes to one consumer that processes it and acknowledges. Good for work distribution and background jobs.
  • Pub/sub (Redis pub/sub, Google Pub/Sub): a message fans out to every interested subscriber. Good when several services each need their own copy of the news.
  • Event streaming (Kafka, Redpanda): events land in an append-only log that consumers read at their own pace and can replay. Good when you want an ordered history and multiple readers with independent progress.

The mental leap from queues to streaming is retention. A queue message is usually gone once acknowledged. A Kafka topic keeps events around, so a new consumer can join next month and read everything from the beginning. That replay ability is genuinely powerful. It's also a lot more infrastructure to run and reason about.

Starting in-process: Laravel events

You don't need Kafka to think in events. If you're on Laravel, you already have a perfectly good in-process event system: dispatch an event, register listeners, done. It's synchronous by default and lives entirely inside one application.

// Producer: something happened
OrderPlaced::dispatch($order);

// Consumer: react to it
class SendOrderConfirmation
{
    public function handle(OrderPlaced $event): void
    {
        // email the customer
    }
}

Make the listener implement ShouldQueue and Laravel pushes it onto a queue instead of running it inline. Now your controller returns fast and the email goes out in a worker. That's your first real taste of asynchronous, decoupled reactions: no broker, no distributed system, no new ops burden.

For a deeper walk through the mechanics, I wrote up Laravel events and listeners separately. The short version: in-process events are a great place to start because they let you practice the decoupling habit without paying the distributed-systems tax.

The tax comes due the moment you cross a service boundary. In-process events die with the process. If your billing service and your notification service are separate deployables, an in-memory dispatch reaches neither. That's when you reach for an actual broker.

Choreography vs orchestration

Once several services react to events, you have to decide who's in charge of the workflow. Two philosophies:

Choreography. Each service listens for events and reacts on its own. Order service publishes OrderPlaced; inventory service hears it and reserves stock, then publishes StockReserved; shipping service hears that and does its part. No central conductor. Every service knows only its own cue.

  • Upside: extremely loose coupling, easy to add a new reaction without touching existing services.
  • Downside: the overall process isn't written down anywhere. To understand "what happens when an order is placed," you have to trace events across six codebases. I've spent whole afternoons drawing that flow on a whiteboard because no single file described it.

Orchestration. A central coordinator (often a "saga" or workflow engine) explicitly calls each step and decides what happens next.

  • Upside: the business process lives in one readable place; compensating actions on failure are easier to model.
  • Downside: that coordinator becomes a hub everything depends on, which quietly reintroduces some of the coupling you were trying to escape.

Neither is "correct." I lean toward choreography for genuinely independent reactions (send email, update read model) and orchestration for multi-step processes with money or inventory on the line, where I need to see the whole flow and handle partial failure deliberately.

The real benefits

Here's what event-driven architecture buys you when it fits:

  • Loose coupling. Producers don't import, call, or even know about consumers. You can add a new consumer (say, a fraud-check service) without redeploying the order service.
  • Independent scaling. A slow consumer doesn't drag the producer down. If email sending is backed up, orders still get placed; the queue absorbs the backlog and workers catch up.
  • Audit trail. With an event log you get a truthful, ordered record of what happened. That's gold for debugging, analytics, and reconstructing state after a bug.
  • Failure isolation. If the analytics consumer crashes, orders and payments keep flowing. The blast radius of a bug shrinks to one consumer.

These are real, and they're the reason the pattern exists. But every one of them has a matching cost.

The costs nobody puts on the sales slide

This is the section I wish more introductions led with.

Eventual consistency. The moment you fire an event and let consumers catch up, different parts of your system are briefly out of sync. The order exists but the search index hasn't indexed it yet; the payment cleared but the loyalty points haven't posted. Your UI and your product team have to accept "it'll be right in a second." A lot of business logic quietly assumes immediate consistency, and that assumption breaks here.

Debugging and tracing get harder. A synchronous stack trace tells you the whole story in one place. An event flow doesn't. A single user action becomes a scatter of log lines across services, out of order, some delayed by retries. Without correlation IDs threaded through every event, you're guessing. Invest in distributed tracing early. It's not optional at scale.

Ordering isn't free. Networks reorder things. Consumers restart. You may receive OrderShipped before OrderPaid if you're not careful. Kafka gives you ordering within a partition, not across a whole topic, and most other transports give you fewer guarantees than you'd hope. Design so that out-of-order delivery is survivable.

You need idempotency. Almost all messaging is at-least-once: a broker will occasionally deliver the same event twice, usually because a consumer processed it but crashed before acknowledging. If your consumer charges a card or increments a counter, a duplicate is a real bug. Consumers must be idempotent: processing the same event twice has the same effect as once. This pairs directly with safe retries; I go deep on the mechanics in idempotency keys for safe API retries.

Retries need backoff. When a consumer fails, you retry. Retry immediately and in a tight loop and you'll hammer a struggling downstream service into the ground. Space retries out with exponential backoff and cap them, then route the permanent failures to a dead-letter queue so they don't block everything behind them.

The dual-write problem. This one is subtle and it catches good engineers. Your handler does two things: write to the database and publish an event. What if the DB commit succeeds but the publish fails (or vice versa)? Now your state and your event stream disagree, permanently.

BAD:  save order to DB     -> success
      publish OrderPlaced  -> network blip, lost
      result: order exists, but nobody was ever told

The standard fix is the transactional outbox: within the same database transaction as your business write, insert the event into an outbox table. A separate process reads that table and publishes to the broker, marking rows as sent.

save order + insert into outbox   -> ONE transaction, atomic
[separate relay] read outbox -> publish -> mark sent

Now the event is guaranteed to exist wherever the order does. It might get published twice (the relay crashes after publishing, before marking sent), which is exactly why your consumers had to be idempotent anyway. The pieces reinforce each other. Race conditions around this kind of shared state are their own topic; preventing race conditions in a web app covers the concurrency side.

When it's worth it, and when it isn't

Trade-offs I actually weigh before reaching for a broker:

  • Team size and boundaries. One team, one monolith? In-process Laravel events probably cover you. Multiple teams owning separate services that must react to each other? That's the case events were made for.
  • Consistency needs. If a workflow genuinely cannot tolerate being briefly out of sync (think inventory you're overselling), you'll spend real effort compensating for eventual consistency. Sometimes a plain synchronous call is the honest choice.
  • Operational maturity. A broker is another thing to run, monitor, upgrade, and page someone about at 3am. If you don't yet have tracing, dashboards, and alerting, adding Kafka multiplies your unknowns.
  • Volume and coupling pain. If your endpoints aren't slow and your services aren't tangled, you may be buying a solution to a problem you don't have. Adopt events because a specific pain is real, not because the architecture diagram looks impressive.

My rule of thumb: start with in-process events to build the decoupling habit, move a consumer to an async queue when latency demands it, and only introduce a real broker when you cross service boundaries and the coordination cost of direct calls has become obvious.

FAQ

Is event-driven architecture the same as microservices?

No. They pair well but they're independent. You can run event-driven patterns inside a single monolith (that's exactly what Laravel events are), and you can build microservices that only talk over synchronous HTTP. Events are about how components communicate, not how you deploy them.

Do I need Kafka to do this?

Not to start. A message queue you already run (Laravel's queue on Redis, or SQS) handles background reactions fine. Kafka earns its keep when you need event replay, an ordered durable log, or many independent consumers reading the same stream at their own pace. Reach for it when those needs are concrete, not by default.

How do I handle a consumer that keeps failing?

Retry with exponential backoff and a maximum attempt count, then send the message to a dead-letter queue for inspection instead of retrying forever. Keep the consumer idempotent so retries can't cause double effects, and make sure your logs carry a correlation ID so you can trace the failing message back to the original action.

Won't eventual consistency confuse my users?

It can, so design the UX for it. Show optimistic UI ("we've received your order") rather than asserting a downstream state that hasn't settled. For flows where users truly need an immediate, consistent answer, keep those specific steps synchronous and let the non-critical reactions go async.

Wrapping up

Event-driven architecture isn't a free upgrade. It trades the simplicity of one call stack for loose coupling, independent scaling, and an honest history of what happened - and it charges you in eventual consistency, harder debugging, ordering headaches, and the need for idempotency, sensible retries, and an outbox to keep your writes and your events in agreement.

If you're on Laravel, the cheapest possible experiment costs you nothing new: dispatch a domain event, move one listener onto the queue, and watch your controller get faster. Live with that for a while. When you eventually cross a service boundary and feel the pull toward a broker, you'll already understand the shape of what you're building - and, more usefully, what it's going to cost.

Share:
Jakub Owsianka
Written by

PHP & Laravel developer with 10+ years of experience. I write about modern backend, architecture, DevOps and AI for developers.

More about the author
Follow