DEV Community

Marcos Nobre
Marcos Nobre

Posted on

How to structure Stripe, Mercado Pago, and Asaas in one Laravel codebase

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;
}
Enter fullscreen mode Exit fullscreen mode

Then resolve the driver by name through a tiny manager:

$driver = $manager->driver('stripe'); // or 'mercadopago', 'asaas'
Enter fullscreen mode Exit fullscreen mode

Usage is identical no matter which provider is behind it:

$subscription = $driver->createSubscription($user, 'price_123', ['quantity' => 1]);

$driver->cancelSubscription('sub_456');
Enter fullscreen mode Exit fullscreen mode

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.

The same abstraction handles webhooks cleanly: 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.

// In your WebhookController
if (! $driver->verifyWebhookSignature($request->getContent(), $signature, $request->headers->all())) {
    abort(400);
}

ProcessWebhook::dispatch($gateway, $request->all())->onQueue('webhooks');

return response()->json(['status' => 'accepted'], 202);
Enter fullscreen mode Exit fullscreen mode

💡 That's also what eventId() is for: inside the job, check whether that event was already processed before acting on it. Gateways retry webhooks, so duplicates are a matter of when, not if.

The Reference Implementation

I've been using this exact shape in production for a while, and I packaged it for Filament so I don't have to rewrite it on every project.

It's called pulsecharge/filament-pulse-charge. The Stripe core is open source (MIT), and the native Mercado Pago + Asaas drivers (with automated PIX) are part of the Pro tier.

If you want to skip the boilerplate and see the full implementation in action, you can get it here:

👉 Get Filament Pulse Charge (Pro) →

(It's a one-time purchase of *$5 USD*)

Top comments (0)