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.