Let’s go back in time.

I’ve got no grey hairs, I’m less grumpy, I’m working for an award-winning e-commerce agency. We’re building features, adding payment gateways, and life is good. The stars are aligned. The codebase? Surely pristine.

Except… nah.

Let’s cut the shit. It sucked.

The codebase sucked. Big fat ones.

Every time we added a new payment gateway, we just slapped another if ($gateway === 'stripe') somewhere in the codebase. Didn’t matter where — controllers, services, random helper classes — we just kept adding logic like:

function pay(string $gateway, float $amount): void {
    if ($gateway === 'stripe') {
        $stripe = new StripeAPI();
        $stripe->createCharge($amount);
    } elseif ($gateway === 'paypal') {
        $paypal = new PayPalAPI();
        $paypal->sendPayment($amount);
    } elseif ($gateway === 'klarna') {
        $klarna = new KlarnaAPI();
        $klarna->processPayment($amount);
    }
}

Absolute carnage.

We were basically duct-taping payment gateways together like we were building Frankenstein’s monster - and every time we added a new gateway, the monster grew another arm.

No tests.
No documentation.
No local development environment.
Just pure, unadulterated cowboy coding. 🤠

I’m not proud of it — but I’ve gotta be honest.

This isn’t about how the entire codebase was an epic cum dumpster.
This is about how we could’ve avoided some pain by using the Adapter Pattern.

But before I show you the elegant, beautiful, clean solution, lets explore the problem..

The Problem

So here's exactly what we were doing every time we added a new payment gateway:

  1. ✅ Find the random class where payment logic lived (because it wasn’t centralised).

  2. ✅ Add another if statement for the new gateway.

  3. ✅ Copy/paste 90% of the logic from another gateway, change a few lines, and hope for the best.

  4. ✅ Ship it to production, cross your fingers, and pray nothing breaks.

And the worst part?

If the Stripe API changed, or the PayPal API updated, we had to hunt down every if ($gateway === 'stripe') across the codebase and manually fix it.

Ok, but why was the code trash?

Problem #1: Tightly Coupled Code

The PaymentService class directly depended on StripeAPI, PayPalAPI, and KlarnaAPI.

Meaning if Stripe changed their API from:

$stripe->createCharge($amount);

to:

$stripe->processPayment($amount);

The entire app would implode.

You'd have to:

  • Manually update 5 different files.

  • Test 3 different payment methods.

  • Hope you didn’t miss anything.

And spoiler: you always missed something.

Problem #2: Open/Closed Principle? Nah.

The Open/Closed Principle (from SOLID) says:

Code should be open for extension but closed for modification.

But our codebase? It was like:

Open for modification, closed for common sense.

Adding a new payment gateway always meant modifying the core payment logic.

And the more gateways you added, the messier it got.

Problem #3: Zero Testability

How do you even test this nightmare?

You can’t.

The logic is embedded in the code like a tick in a big foots ass crack.

  • You can’t mock the payment API.

  • You can’t unit test the payment service.

  • You can’t isolate logic without spinning up a full-blown test server.

So we just did what any responsible developer would do.
We shipped it untested and hoped for the best.

Using the Adapter Pattern

Here’s what we SHOULD have used years ago.

The Adapter Pattern works like a translator.

  • Your app speaks one language (pay() method).

  • Third-party APIs speak different languages (createCharge(), sendPayment() etc.).

  • The Adapter acts as the middleman, converting the API's language to your app's language.

So instead of this:

if ($gateway === 'stripe') {
    $stripe->createCharge($amount);
}

You can do this:

$gateway = new StripeAdapter();
$gateway->pay($amount);

A Quick Code Example

First, lets create a common interface:

interface PaymentGateway
{
    public function pay(float $amount): void;
}

Then let’s build adapters for each gateway:

class StripeAdapter implements PaymentGateway
{
    private StripeAPI $stripe;

    public function __construct()
    {
        $this->stripe = new StripeAPI();
    }

    public function pay(float $amount): void
    {
        $this->stripe->createCharge($amount);
    }
}


class PayPalAdapter implements PaymentGateway
{
    private PayPalAPI $paypal;

    public function __construct()
    {
        $this->paypal = new PayPalAPI();
    }

    public function pay(float $amount): void
    {
        $this->paypal->sendPayment($amount);
    }
}

Remember this monstrosity?

function pay(string $gateway, float $amount): void {
    if ($gateway === 'stripe') {
        $stripe = new StripeAPI();
        $stripe->createCharge($amount);
    } elseif ($gateway === 'paypal') {
        $paypal = new PayPalAPI();
        $paypal->sendPayment($amount);
    } elseif ($gateway === 'klarna') {
        $klarna = new KlarnaAPI();
        $klarna->processPayment($amount);
    }
}

With the Adapter implementation, it’d simply look something like this.

function pay(PaymentGateway $gateway, float $amount): void {
    $gateway->pay($amount);
}

That’s it.

Closing Comments

The Adapter Pattern is one of those things that feels like overengineering — until it saves you from a massive code refactor down the line.

Next time you’re integrating a third-party service (payment gateways, file storage, SMS providers, etc.), reach for the Adapter Pattern.

Your future self will thank you. 😉

It’s exactly how Laravel's mail, queue, and file storage systems work under the hood.

  • Different mail drivers (SMTP, Mailgun, Postmark) → One Mail facade.

  • Different file systems (S3, local, FTP) → One Storage facade.

  • Different queues (Redis, database, SQS) → One Queue facade.

Want a PHP example? Check out my code tips repository.

The Grumpy Dev is a reader-supported publication. To receive new posts and support my work, consider becoming a free or paid subscriber.