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
| Term | Meaning | Payment example |
|---|---|---|
| Topic | Named, append-only log of events | payments.captured |
| Partition | A topic is split into partitions for parallelism; order is guaranteed only within one partition | 12 partitions |
| Key | Decides the partition; same key, same partition, same order | merchant_id or account_id |
| Producer | Writes events | Payment service |
| Consumer group | A set of instances sharing the work; each partition goes to one instance in the group | settlement-service, fraud-service, notification-service |
| Offset | Position of a consumer in a partition, committed after processing | settlement at offset 99120 |
| Retention | How long events are kept, independent of consumption | 7 days, or compacted forever |
One event, many independent consumers
- Payment service produces PaymentCaptured(key = merchant_id)
- Topic payments.captured
- Settlement group: credits merchant ledger
- Notification group: SMS + email
- Fraud group: scores the payment
- 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()]);
});
| Guarantee | Meaning | How |
|---|---|---|
| At most once | May lose events, never duplicates | Commit offset before processing |
| At least once | Never loses, may duplicate | Commit offset after processing (default choice) |
| Exactly once (effectively) | Each event affects state once | At least once + idempotent consumer, or Kafka transactions within Kafka |
6. End-to-end flows
E-commerce order
- OrderPlaced
- Inventory reserves stock
- Payment captures
- PaymentCaptured
- Warehouse picks and ships
- OrderShipped
- Notification sends tracking SMS
Bank transfer (NEFT/IMPS)
- TransferRequested
- Fraud scoring (may hold)
- Core banking debits sender
- FundsDebited
- Switch sends to beneficiary bank
- FundsTransferred or TransferFailed (auto-reversal)
- Customer alert + statement entry
Payment gateway refund
- RefundRequested (merchant API)
- Check refundable balance
- Call card network / UPI
- RefundProcessed
- Merchant webhook
- 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.
