Paysasa
Problem
Every Kenyan Laravel project ends up writing the same brittle payment code. First it is Safaricom's Daraja API for M-Pesa, then Stripe for cards, then Pesapal or Flutterwave when a client wants hosted checkout. Each provider has its own auth scheme, its own webhook shape and its own retry behaviour. In most codebases that logic is scattered across controllers, with no shared persistence model, no protection against duplicate charges and no events for the rest of the app to react to.
The result is payment code that is hard to test, hard to audit and painful to change when a business adds a second provider.
Solution
Paysasa is a driver-based payment gateway abstraction for Laravel 11, 12 and 13, built for the Kenyan market. It gives an application one consistent, fluent API for every provider:
Payment::driver('mpesa')
->amount(2500)
->currency('KES')
->phone('254712345678')
->reference('INV1001')
->charge();
Switching to Stripe, Paystack or PesaLink is a one-word change. Every driver returns the same PaymentResponse object (successful(), pending(), failed(), transactionId(), receiptNumber() and so on), so business logic is written once against the abstraction and never against a specific gateway.
Supported rails (13 drivers):
- Mobile money: M-Pesa (STK push, C2B, B2C, reversal, balance), Airtel Money, T-Kash
- Cards: Stripe, Pesapal, Flutterwave, Paystack
- Wallets: Google Pay, Apple Pay
- Banking: PesaLink, EFT, RTGS, virtual accounts
System Architecture
Paysasa follows a Clean Architecture / DDD-lite layout, adapted to what a Laravel package needs. The aim is testability and provider-swappability, not framework independence.
- Facade → Manager → Fluent builder. The
Paymentfacade resolves drivers through aPaymentManagerthat follows Laravel's own Manager pattern, the same one used by Cache, Mail and Queue. - Contracts and capability interfaces.
PaymentDriveronly requirescharge()andverify(). Optional abilities such asRefundable,PayoutCapable,ReversibleandBalanceInquirableare separate interfaces (Interface Segregation). A caller checksinstanceof Refundableinstead of calling a method that throws on half the drivers. - Actions as the single orchestration layer.
InitiatePayment,VerifyPayment,ProcessRefundandRunFraudChecksare the only places the sequencing is defined: fraud check, idempotency wrap, persist, call driver, persist result, fire event. The flow is identical whatever the entry point. - Thin, provider-faithful HTTP clients.
DarajaClient,StripeClientand the others hold no business logic, so a provider's wire format can change without touching driver logic. - Repository pattern. Persistence sits behind a
PaymentRepositorycontract with an Eloquent implementation. - Strategy pattern for webhooks. Each provider has its own
SignatureVerifier, selected by aWebhookDispatcher. - Extensible by design. Third-party drivers plug in through
extend()without touching core. Every driver constructor has the same signature, which keeps this trivial.
Wallets are adapters, not rails
Google Pay and Apple Pay are not independent settlement networks. They are tokenization layers on top of the card networks. Paysasa models them as adapters that delegate settlement to a configured card gateway (Stripe by default), rather than inventing a settlement API that doesn't exist. This keeps the system honest about where money actually moves, which matters when explaining it to a PCI auditor.
Reliability
Payment lifecycle
Payments move through an explicit state machine: Pending → Processing → Authorized → Successful, with Failed, Cancelled, Expired, Refunded, PartiallyRefunded and Reversed as the other states. The terminal states are defined in one place (PaymentStatus::isTerminal()).
Asynchronous providers
STK push and hosted checkouts don't settle in the request/response cycle. Paysasa handles this in two ways:
- Webhooks. The webhook is acknowledged fast and processed on the queue by
DispatchWebhookJob, with retries and backoff. - Polling safety net. A
VerifyPaymentStatusJobre-queries the provider with increasing delays in case a webhook never arrives. It stops as soon as a terminal state is reached.
Event-driven integration
The package fires 11 lifecycle events (PaymentInitiated, PaymentSuccessful, PaymentFailed, PaymentRefunded, WebhookProcessed and others). The host application decides what happens next (mark an invoice paid, send a receipt) with ordinary Laravel listeners. PaymentSuccessful and PaymentFailed can also broadcast on a private channel, so a UI can update live while the customer is looking at the STK prompt.
Error handling
A typed exception hierarchy (ProviderApiException, WebhookVerificationException, IdempotencyConflictException, FraudSuspectedException and others) lets callers catch precisely. Normal "payment failed" cases come back as a PaymentResponse, not an exception. Exceptions are reserved for genuinely exceptional conditions such as misconfiguration.
Security
Security is built into the design rather than added afterwards:
- Webhook verification. Every inbound webhook is checked by a provider-specific signature verifier before it is trusted. The raw payload is persisted before verification, so a failed attempt is kept as forensic evidence of spoofing.
- Idempotency. Every
charge()is wrapped by anIdempotencyManager. A repeated key with the same payload returns the original response without calling the provider again, which protects against double-taps, frontend retries and job replays. Reusing a key with a different payload fails loudly withIdempotencyConflictException. - Replay protection. Webhook payloads are fingerprint-deduplicated, and Stripe signatures older than five minutes are rejected.
- Constant-time comparisons. HMAC checks use
hash_equals(). - Rate limiting. Webhook endpoints are throttled per source IP, independent of the host app's own throttling.
- Encrypted credentials. Per-merchant provider credentials are encrypted at rest and hidden from serialization. Logs redact sensitive fields such as PINs, card numbers and CVVs.
- Reduced PCI scope. The host app never handles raw card data. Card charges use client-side tokens, so integrators stay out of the heaviest PCI scope (SAQ A or A-EP rather than SAQ D).
- Fraud hooks. A
FraudCheckcontract lets integrators run custom checks, such as a velocity limit, before any charge reaches a provider.
Data Model
Nine tables cover the payment lifecycle: payments, transactions, payment_attempts, refunds, webhooks, payment_logs, provider_accounts, payment_methods and audit_logs.
Multi-Merchant Support
Payment::forMerchant($id)->driver('mpesa') resolves credentials per merchant from encrypted provider_accounts rows, falling back to config defaults. This lets one application act on behalf of many merchants, as a marketplace or SaaS platform would.
Developer Experience
- One-command install:
composer require paysasa/paymentsandphp artisan paysasa:install --migrate. - First-class testing:
Payment::fake()never touches the database, the queue or the network, and ships assertions such asassertCharged(),assertChargedOn('mpesa')andassertChargedTimes(1). - Operational commands:
paysasa:statuschecks every configured driver, andpaysasa:mpesa:register-urlshandles one-time Daraja URL registration. - Quality tooling: the repository includes a Pest test suite and PHPStan static analysis.
- Documentation: a 14-document guide in the repo covers architecture, database schema, webhooks, security, testing, performance, troubleshooting and writing custom drivers.
Takeaway
Paysasa turns a tangle of per-provider integration code into one tested, extensible layer. It handles the hard parts of payments (idempotency, webhook trust, async settlement, auditability) once, so application code only has to say what to charge and react to what happened.