If you're building a SaaS for the Latin American market, you will inevitably face a hard architectural decision: how do you support multiple payment providers without turning your codebase into a mess of if/else statements?
You might start with Stripe, since it's the standard for international recurring billing. Then a client asks for PIX, and you need Mercado Pago. Another client wants boleto bancário, and now you need Asaas.
If you're not careful, your PaymentController turns into a 500-line monster full of provider-specific logic.
Here's the architecture I use to solve this: the Driver/Adapter pattern with a typed interface.
The Core Idea
The goal is simple: your domain depends on a single contract, and each provider is just a new class. You never have to refactor your controllers or jobs when a new gateway is added.
Define the contract around your business operations, not around the provider's API:
interface GatewayDriverInterface
{
public function createCustomer(Model $billable): string;
public function createSubscription(Model $billable, string $planGatewayId, array $options = []): array;
public function cancelSubscription(string $gatewaySubscriptionId): bool;
public function verifyWebhookSignature(string $rawBody, string $signature, array $headers = []): bool;
public function handleWebhook(array $payload, string $signature): bool;
public function eventId(array $payload): string;
}
Then resolve the driver by name through a tiny manager:
$driver = $manager->driver('stripe');
Usage is identical no matter which provider is behind it:
$subscription = $driver->createSubscription($user, 'price_123', ['quantity' => 1]);
$driver->cancelSubscription('sub_456');
Two Rules That Save You Later
There are two things worth insisting on from day one, because they will bite you if you don't.
1. Return structured arrays, not raw provider payloads
Each driver normalizes its response into the same shape, so Stripe's and Mercado Pago's payloads never leak into your domain. If you return the raw JSON, you're coupling your business logic to the provider's schema.
2. Store money in cents, as integers
Floating-point prices are how you end up with $29.9000000001 charges and reconciliation bugs. Always use integers (e.g., 2990 for $29.90).
Handling Webhooks Without Blocking
Payment webhooks are the hardest part of this architecture, and you shouldn't process them synchronously. Your controller verifies the signature for each gateway (fail fast), then dispatches a queued job. The provider is never kept waiting, and transient failures are retried with exponential backoff.
if (! $driver->verifyWebhookSignature($request->getContent(), $signature, $request->headers->all())) {
abort(400);
}
ProcessWebhook::dispatch($gateway, $request->all())->onQueue('webhooks');
return response()->json(['status' => 'accepted'], 202);
What if the same webhook arrives twice?
Or three times? That's where idempotency comes in. Inside the job, use eventId() to check whether that event was already processed before acting on it. Gateways retry webhooks, so duplicates are a matter of when, not if.
Beyond the Gateways
I've been using this exact shape in production, but a robust billing system involves much more than connecting gateways. It also needs:
- Idempotent webhook processing, so the same event never updates a subscription or a payment twice.
- A dunning engine that automatically retries failed payments to recover revenue.
- Executive dashboards with MRR, churn and LTV metrics.
Building all of this from scratch takes weeks, so I packaged it as pulsecharge/filament-pulse-charge, a Filament plugin. The Stripe core is open source (MIT). The native Mercado Pago and Asaas drivers (with automated PIX), idempotency, dunning and the dashboards are part of the Pro tier.
If you want to skip the boilerplate and see the full implementation in action:
👉 Get Filament Pulse Charge (Pro) →
One-time purchase, lifetime access.

Top comments (1)
The gateway contract is most valuable at the webhook boundary, where retries turn an integration detail into a correctness issue. I would test each driver with the same duplicate-event fixture and assert one normalized state transition plus an audit record that links the provider event ID to the internal action.