Insights into Company
← Backend Concepts in Depth topics

Events & Kafka in Banking, E-commerce and Payments

Event-driven design from Laravel events and listeners up to Kafka topics: what an event is, producers and consumers, partitions and ordering, consumer groups, delivery guarantees, the outbox pattern, and complete flows for an order, a payment and a bank transfer.

Running examples: OrderPlaced, PaymentCaptured, FundsTransferred events consumed by many services

1. Events: announcing what happened

An event is a fact about the past: OrderPlaced, PaymentCaptured, FundsTransferred. The code that produces it does not know or care who reacts. Listeners subscribe and do their part: send email, reduce stock, award loyalty points, update analytics.

This removes coupling. Without events, OrderService calls Mailer, Inventory, Loyalty, Analytics and Warehouse directly, so adding a new reaction means editing checkout code. With events, you add a listener and checkout never changes.

Laravel events and queued listeners

class OrderPlaced
{
    use Dispatchable, SerializesModels;
    public function __construct(public Order $order) {}
}

// EventServiceProvider
protected $listen = [
    OrderPlaced::class => [SendOrderConfirmation::class, ReserveStock::class, AwardLoyaltyPoints::class],
];

class AwardLoyaltyPoints implements ShouldQueue          // runs on a worker, not in the checkout request
{
    public function handle(OrderPlaced $event): void
    {
        $event->order->customer->increment('points', intdiv($event->order->total_paise, 10000));
    }
}

// In OrderService, after the transaction commits
DB::afterCommit(fn () => OrderPlaced::dispatch($order));

Laravel events live inside one application. When several separately deployed services (orders, payments, notifications, fraud, analytics) must react, events need a broker that stores them durably and lets each service read at its own pace. That is Kafka.

2. Kafka building blocks

Core terms
TermMeaningPayment example
TopicNamed, append-only log of eventspayments.captured
PartitionA topic is split into partitions for parallelism; order is guaranteed only within one partition12 partitions
KeyDecides the partition; same key, same partition, same ordermerchant_id or account_id
ProducerWrites eventsPayment service
Consumer groupA set of instances sharing the work; each partition goes to one instance in the groupsettlement-service, fraud-service, notification-service
OffsetPosition of a consumer in a partition, committed after processingsettlement at offset 99120
RetentionHow long events are kept, independent of consumption7 days, or compacted forever

One event, many independent consumers

  1. Payment service produces PaymentCaptured(key = merchant_id)
  2. Topic payments.captured
  3. Settlement group: credits merchant ledger
  4. Notification group: SMS + email
  5. Fraud group: scores the payment
  6. Analytics group: dashboards

Unlike a job queue where a message is deleted once handled, Kafka keeps events for the retention period. A new service (say, a cashback engine) can start reading from the beginning and catch up. Each consumer group tracks its own offset, so a slow analytics service never delays settlement.

Ordering: all events for account 42 must be applied in order (debit before the balance check of the next transfer). Using account_id as the key sends them to the same partition, where order is preserved.

3. Producing and consuming in Spring Boot

Producer and idempotent consumer

// application.yml: spring.kafka.producer.acks=all, enable.idempotence=true (no duplicates from producer retries)
@Service
@RequiredArgsConstructor
class PaymentEvents {
    private final KafkaTemplate<String, PaymentCaptured> kafka;
    void captured(Payment p) {
        kafka.send("payments.captured", p.getMerchantId(), new PaymentCaptured(p.getId(), p.getMerchantId(), p.getAmountPaise(), Instant.now()));
    }
}

@Component
class SettlementConsumer {
    @KafkaListener(topics = "payments.captured", groupId = "settlement-service", concurrency = "4")
    @Transactional
    public void on(PaymentCaptured e) {
        if (processed.existsById(e.paymentId())) return;            // at-least-once delivery => dedupe
        ledger.creditMerchant(e.merchantId(), e.amountPaise());
        processed.save(new ProcessedEvent(e.paymentId()));
    }
}

4. Kafka from Laravel

Publishing with a Kafka client package (mateusjunges/laravel-kafka)

use Junges\Kafka\Facades\Kafka;
use Junges\Kafka\Message\Message;

Kafka::publish('kafka:9092')->onTopic('orders.placed')
    ->withMessage(new Message(key: (string) $order->customer_id, body: [
        'event_id' => (string) Str::uuid(), 'order_id' => $order->number, 'total_paise' => $order->total_paise, 'occurred_at' => now()->toIso8601String(),
    ]))->send();

// Consumer as an artisan command run by Supervisor
Kafka::consumer(['orders.placed'], 'warehouse-service')
    ->withHandler(function (ConsumerMessage $message) {
        $body = $message->getBody();
        if (ProcessedEvent::where('event_id', $body['event_id'])->exists()) return;
        DB::transaction(function () use ($body) {
            Shipment::create(['order_number' => $body['order_id'], 'status' => 'picking']);
            ProcessedEvent::create(['event_id' => $body['event_id']]);
        });
    })->build()->consume();

This portal has a KafkaPublisher that stays off unless KAFKA_ENABLED=true, because shared hosting has no Kafka broker. The same events are handled by Laravel listeners and queues there.

5. Delivery guarantees and the outbox pattern

The dual-write problem: you commit the payment row to MySQL, then publish to Kafka, and the process crashes in between. The database says "captured" but no one ever settles the merchant. Publishing first is worse: the event exists but the transaction may roll back.

The transactional outbox fixes it: in the same database transaction, insert the event into an outbox table. A separate relay (a scheduled job, or Debezium reading the MySQL binlog) publishes outbox rows to Kafka and marks them sent. If the relay crashes it publishes again, so consumers must be idempotent: at-least-once delivery plus dedupe gives effectively-once processing.

Outbox in Laravel + MySQL

DB::transaction(function () use ($payment) {
    $payment->update(['status' => 'captured']);
    DB::table('outbox')->insert([
        'id' => (string) Str::uuid(), 'topic' => 'payments.captured', 'key' => (string) $payment->merchant_id,
        'payload' => json_encode(['payment_id' => $payment->id, 'amount_paise' => $payment->amount_paise]),
        'created_at' => now(),
    ]);
});

// Scheduled every minute: relay unsent rows
DB::table('outbox')->whereNull('sent_at')->orderBy('created_at')->limit(500)->get()->each(function ($row) use ($kafka) {
    $kafka->publish($row->topic, $row->key, $row->payload);
    DB::table('outbox')->where('id', $row->id)->update(['sent_at' => now()]);
});
Delivery semantics
GuaranteeMeaningHow
At most onceMay lose events, never duplicatesCommit offset before processing
At least onceNever loses, may duplicateCommit offset after processing (default choice)
Exactly once (effectively)Each event affects state onceAt least once + idempotent consumer, or Kafka transactions within Kafka

6. End-to-end flows

E-commerce order

  1. OrderPlaced
  2. Inventory reserves stock
  3. Payment captures
  4. PaymentCaptured
  5. Warehouse picks and ships
  6. OrderShipped
  7. Notification sends tracking SMS

Bank transfer (NEFT/IMPS)

  1. TransferRequested
  2. Fraud scoring (may hold)
  3. Core banking debits sender
  4. FundsDebited
  5. Switch sends to beneficiary bank
  6. FundsTransferred or TransferFailed (auto-reversal)
  7. Customer alert + statement entry

Payment gateway refund

  1. RefundRequested (merchant API)
  2. Check refundable balance
  3. Call card network / UPI
  4. RefundProcessed
  5. Merchant webhook
  6. Settlement adjusts next payout

A failure part-way (stock reserved but payment failed) is undone with compensating events (StockReleased) rather than a distributed transaction. This is the saga pattern; each step has a defined compensation.

7. Showing events live in React

Browsers do not connect to Kafka. A backend service consumes the topic and pushes updates to the browser over WebSockets (Laravel Reverb or Pusher, Spring WebSocket/STOMP) or Server-Sent Events. Typical uses: live order tracking, "payment received" on a merchant dashboard.

Listening for a payment update with Laravel Echo

useEffect(() => {
  const channel = window.Echo.private(`merchant.${merchantId}`)
    .listen('.payment.captured', e => setPayments(list => [e.payment, ...list]));
  return () => window.Echo.leave(`merchant.${merchantId}`);
}, [merchantId]);

8. Interview questions

How do you keep events for one account in order?

Use account_id as the message key so all its events go to one partition, and process each partition sequentially.

What happens if there are more consumers than partitions?

Extra consumers in the group sit idle; partitions are the unit of parallelism.

Kafka vs a job queue?

A queue delivers each job to one worker and deletes it; Kafka keeps an ordered log that many groups read independently and can replay.

What is the outbox pattern for?

Atomically saving state and the event to publish, avoiding lost or phantom events when the DB and Kafka cannot share a transaction.