The first payment gateway is easy. You follow the provider's SDK, wire up a controller, and ship. The trouble starts at the second one — and by the time a client asked me to support a fifth, the "just add another if" approach would have turned the checkout into a swamp.

What kept it clean across seven gateways was refusing to let the rest of the app know which provider it was talking to. The application asks for a payment; it does not care who processes it.

One contract every gateway must honour

Everything starts with a single interface. Each provider implements it, and nothing outside the payment layer ever sees a provider class directly.

interface PaymentGateway
{
    public function charge(PaymentRequest $request): PaymentResult;

    public function verifyWebhook(Request $request): WebhookEvent;
}

PaymentRequest and PaymentResult are my own value objects, not the provider's. This is the part people skip, and it is the part that matters most: the moment a Paymob-shaped array leaks into a controller, you are coupled to Paymob forever. Translating to and from my own shapes at the boundary is what makes the providers swappable.

A driver per provider, resolved by name

Each gateway is a small, self-contained class. They never reference each other.

final class PayTabsGateway implements PaymentGateway
{
    public function charge(PaymentRequest $request): PaymentResult
    {
        $response = Http::withToken(config('services.paytabs.key'))
            ->post('https://secure.paytabs.com/payment/request', [
                'cart_amount' => $request->amount,
                'cart_currency' => $request->currency,
                // ...provider-specific mapping lives ONLY here
            ]);

        return PaymentResult::fromPayTabs($response->json());
    }
}

A small manager resolves the right driver from config, so the rest of the app stays provider-agnostic:

$gateway = PaymentManager::driver($order->gateway); // 'paytabs', 'paymob', ...
$result  = $gateway->charge($paymentRequest);

Adding the eighth gateway is now a new class and one config line. No existing file changes — which is exactly the open–closed principle doing its job, and exactly what you want touching money.

Webhooks are where the real money is — and the real bugs

The charge call is the easy half. Most failures I have debugged live in the webhook: the provider calls you back to confirm payment, and that callback is hostile by default. It can arrive twice, arrive out of order, or be forged.

Three rules I never break:

  • Verify the signature first, before you read anything. Every serious provider signs its webhooks. If the signature fails, it is a 403, not a payment.
  • Make processing idempotent. I store the provider's transaction id with a unique constraint and treat a duplicate as success without re-crediting. The webhook firing twice should be a no-op, not a double order.
  • Acknowledge fast, work later. Verify, persist the raw event, return 200, and push the heavy work (fulfilment, emails, invoicing) onto a queue. Providers retry on timeouts, and a slow webhook handler quietly becomes a duplicate-delivery machine.
public function handle(Request $request, string $provider)
{
    $event = PaymentManager::driver($provider)->verifyWebhook($request); // throws on bad signature

    ProcessPaymentEvent::dispatch($event); // queued, idempotent

    return response()->noContent(); // 200, immediately
}

What this buys you

The payoff is not elegance for its own sake. It is that a new market — a client who needs STC Pay for Saudi or Tabby for instalments — becomes a contained change instead of a risky one. The checkout flow, the order model, the receipts, the tests: none of them know or care that a gateway was added. With money on the line, "nothing else had to change" is the highest compliment an architecture can earn.