DEV Community

Cover image for Don't Mock What You Don't Own: The Stripe Adapter Pattern in PHP
Gabriel Anhaia
Gabriel Anhaia

Posted on

Don't Mock What You Don't Own: The Stripe Adapter Pattern in PHP


Open the test suite of any PHP app that takes money and you will find a line that looks like this:

$stripe = Mockery::mock(\Stripe\StripeClient::class);
$stripe->paymentIntents = Mockery::mock();
$stripe->paymentIntents->shouldReceive('create')
    ->with(Mockery::on(fn($a) => $a['amount'] === 2000))
    ->andReturn((object) ['id' => 'pi_123', 'status' => 'succeeded']);
Enter fullscreen mode Exit fullscreen mode

The test passes. It will keep passing forever. It will keep passing the day Stripe changes the shape of paymentIntents->create, the day the SDK starts returning a typed DTO instead of stdClass, the day a field you read in production stops being populated for new accounts.

That is the bug. You mocked a class you don't own. The test is now a frozen snapshot of your guess about somebody else's API, and the snapshot is wrong the moment they ship.

There is a rule from the GOOS book (2009) that fixes this: don't mock what you don't own. The thing you mock should be an interface in your codebase, written in your language, that you control. Anything sitting at the network edge goes behind one of those interfaces: Stripe, Twilio, S3, the Slack SDK. Then you mock the interface, and you write one contract test that runs the real adapter against the real sandbox to catch the day Stripe actually moves.

Here is the shape in PHP 8.3: a PaymentGateway port, a StripeAdapter that wraps the SDK, an InMemoryPaymentGateway for fast tests, and a single contract test that runs against both. By the end you can delete most of your Mockery::mock(StripeClient::class) calls without losing coverage.

Why mocking Stripe directly is a slow leak

A mock of Stripe\StripeClient makes three assumptions on your behalf, and you can't see any of them in the test file:

  1. The method exists with that name. paymentIntents->create() is real today. The next major SDK version might call it paymentIntents->createForCustomer(), or split it. Your tests pass; production breaks.
  2. The arguments and shape are what you remember. The mock expects ['amount' => 2000, 'currency' => 'eur']. Stripe adds a required automatic_payment_methods field for new integrations. Your tests pass; production gets a 400.
  3. The return value matches what you stub. You return (object) ['id' => 'pi_123', 'status' => 'succeeded']. The real SDK returns \Stripe\PaymentIntent, a typed object. Add readonly string $status to a downstream class and the test still works on stdClass while production hands you PaymentIntent::STATUS_REQUIRES_ACTION.

Each assumption is invisible. Nothing declares them: not the test, not the type system, not CI. The only thing that catches a broken assumption is production, which is the worst place to catch anything.

The deeper problem is conceptual. The Stripe SDK is not your domain. Your domain knows about charging a customer for an order and refunding when fulfillment fails. It does not know about PaymentIntent, idempotency keys, or automatic_payment_methods. Every test that talks to StripeClient directly is a test of the wrong layer.

Two test setups side by side: on the left, a checkout class wired directly to a mocked StripeClient with three red question marks over the wire; on the right, the same checkout wired to a PaymentGateway interface, with the mock attached to the interface and the real adapter sitting at the edge.

A port in the language you actually speak

The fix is mechanical. Define an interface in your domain that says what you need from a payment gateway, in your words. No PaymentIntent, no Charge, no idempotency keys leaking through, no Stripe types in the signature.

<?php
declare(strict_types=1);

namespace App\Payments\Domain;

interface PaymentGateway
{
    public function charge(ChargeRequest $request): ChargeResult;

    public function refund(string $chargeId, Money $amount): RefundResult;
}
Enter fullscreen mode Exit fullscreen mode

The port speaks in ChargeRequest, ChargeResult, Money, RefundResult — types that live in App\Payments\Domain. None of them know what Stripe is.

<?php
declare(strict_types=1);

namespace App\Payments\Domain;

final readonly class ChargeRequest
{
    public function __construct(
        public string $orderId,
        public string $customerEmail,
        public Money $amount,
        public string $paymentMethodToken,
        public string $idempotencyKey,
    ) {}
}

final readonly class ChargeResult
{
    public function __construct(
        public string $chargeId,
        public ChargeStatus $status,
        public ?string $declineReason = null,
    ) {}
}

enum ChargeStatus: string
{
    case Succeeded = 'succeeded';
    case RequiresAction = 'requires_action';
    case Declined = 'declined';
}
Enter fullscreen mode Exit fullscreen mode

Money is a small value object — amount in minor units, plus an ISO 4217 currency code. Nothing more.

<?php
declare(strict_types=1);

namespace App\Payments\Domain;

final readonly class Money
{
    public function __construct(
        public int $minorUnits,
        public string $currency,
    ) {
        if ($minorUnits < 0) {
            throw new \InvalidArgumentException('amount must be >= 0');
        }
        if (strlen($currency) !== 3) {
            throw new \InvalidArgumentException('ISO 4217 currency expected');
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

The idempotencyKey is on the port on purpose. It is not a Stripe concept — every payment gateway needs replay protection on the wire, and the caller is the one who knows what makes a charge unique (usually the order id plus a retry counter). Putting it on the request keeps the caller honest and the adapter dumb.

ChargeStatus deliberately collapses Stripe's seven payment-intent statuses to three. That is the point of a port: it is your vocabulary, not the vendor's. If you ever switch from Stripe to Adyen, Adyen's status set won't fit one-to-one either. You decide what your checkout needs to branch on.

The Stripe adapter, end to end

The adapter is the only place in your codebase that imports anything from Stripe\. It is a thin, boring translator between two type systems.

<?php
declare(strict_types=1);

namespace App\Payments\Infrastructure\Stripe;

use App\Payments\Domain\ChargeRequest;
use App\Payments\Domain\ChargeResult;
use App\Payments\Domain\ChargeStatus;
use App\Payments\Domain\Money;
use App\Payments\Domain\PaymentGateway;
use App\Payments\Domain\RefundResult;
use Stripe\Exception\ApiErrorException;
use Stripe\Exception\CardException;
use Stripe\StripeClient;

final class StripePaymentGateway implements PaymentGateway
{
    public function __construct(private StripeClient $stripe) {}

    public function charge(ChargeRequest $request): ChargeResult
    {
        try {
            $intent = $this->stripe->paymentIntents->create(
                [
                    'amount' => $request->amount->minorUnits,
                    'currency' => strtolower($request->amount->currency),
                    'payment_method' => $request->paymentMethodToken,
                    'confirm' => true,
                    'receipt_email' => $request->customerEmail,
                    'metadata' => ['order_id' => $request->orderId],
                ],
                ['idempotency_key' => $request->idempotencyKey],
            );

            return new ChargeResult(
                chargeId: $intent->id,
                status: $this->mapStatus($intent->status),
            );
        } catch (CardException $e) {
            return new ChargeResult(
                chargeId: $e->getStripeCode() ?? 'unknown',
                status: ChargeStatus::Declined,
                declineReason: $e->getDeclineCode() ?? $e->getMessage(),
            );
        } catch (ApiErrorException $e) {
            throw new PaymentGatewayUnavailable(
                'stripe charge failed: ' . $e->getMessage(),
                previous: $e,
            );
        }
    }

    public function refund(string $chargeId, Money $amount): RefundResult
    {
        try {
            $refund = $this->stripe->refunds->create([
                'payment_intent' => $chargeId,
                'amount' => $amount->minorUnits,
            ]);

            return new RefundResult(
                refundId: $refund->id,
                refunded: true,
            );
        } catch (ApiErrorException $e) {
            throw new PaymentGatewayUnavailable(
                'stripe refund failed: ' . $e->getMessage(),
                previous: $e,
            );
        }
    }

    private function mapStatus(string $stripeStatus): ChargeStatus
    {
        return match ($stripeStatus) {
            'succeeded' => ChargeStatus::Succeeded,
            'requires_action',
            'requires_confirmation',
            'requires_payment_method' => ChargeStatus::RequiresAction,
            default => ChargeStatus::Declined,
        };
    }
}
Enter fullscreen mode Exit fullscreen mode

Exception translation is one half of the job. Stripe's CardException (a declined card) becomes a result, not an exception. Declined cards are a domain outcome; the caller wants to render a "your card was declined" page, not catch an exception named after a vendor. Anything that is not a card decline becomes a PaymentGatewayUnavailable domain exception, which the caller treats as a transient failure.

Status mapping is the other half. Stripe's seven statuses get folded into the three the domain cares about. The map sits in one place, easy to update when Stripe adds a new status.

The adapter is dull on purpose. There is no business logic in it. If the only thing in the file is if-return and match, you are doing it right.

The in-memory adapter does the same job for tests

Now the payoff. A second adapter, in-memory, implementing the same port:

<?php
declare(strict_types=1);

namespace App\Payments\Infrastructure\InMemory;

use App\Payments\Domain\ChargeRequest;
use App\Payments\Domain\ChargeResult;
use App\Payments\Domain\ChargeStatus;
use App\Payments\Domain\Money;
use App\Payments\Domain\PaymentGateway;
use App\Payments\Domain\RefundResult;

final class InMemoryPaymentGateway implements PaymentGateway
{
    /** @var array<string, ChargeResult> */
    private array $charges = [];

    /** @var array<string, ChargeResult> */
    private array $byIdempotencyKey = [];

    private ?string $forcedDecline = null;

    public function chargeWillBeDeclined(string $reason = 'generic_decline'): void
    {
        $this->forcedDecline = $reason;
    }

    public function charge(ChargeRequest $request): ChargeResult
    {
        if (isset($this->byIdempotencyKey[$request->idempotencyKey])) {
            return $this->byIdempotencyKey[$request->idempotencyKey];
        }

        if ($this->forcedDecline !== null) {
            $result = new ChargeResult(
                chargeId: 'declined_' . bin2hex(random_bytes(4)),
                status: ChargeStatus::Declined,
                declineReason: $this->forcedDecline,
            );
        } else {
            $result = new ChargeResult(
                chargeId: 'ch_' . bin2hex(random_bytes(8)),
                status: ChargeStatus::Succeeded,
            );
        }

        $this->charges[$result->chargeId] = $result;
        $this->byIdempotencyKey[$request->idempotencyKey] = $result;
        return $result;
    }

    public function refund(string $chargeId, Money $amount): RefundResult
    {
        if (!isset($this->charges[$chargeId])) {
            return new RefundResult(refundId: '', refunded: false);
        }
        return new RefundResult(
            refundId: 'rf_' . bin2hex(random_bytes(8)),
            refunded: true,
        );
    }
}
Enter fullscreen mode Exit fullscreen mode

It honors idempotency, because the port said it has to. Same idempotency key, same result. That is a behavior the port promises, so every adapter has to deliver it.

It can also be told to decline. chargeWillBeDeclined() is a test affordance, not part of the port. Test fakes are allowed to have configuration methods their production siblings don't have.

And it is fast and deterministic. No network. No Stripe sandbox round trip. Your unit tests can call it ten thousand times per CI run.

Every checkout test in your app uses this fake. None of them know Stripe exists.

<?php
declare(strict_types=1);

use App\Checkout\PlaceOrder;
use App\Payments\Domain\ChargeStatus;
use App\Payments\Domain\Money;
use App\Payments\Infrastructure\InMemory\InMemoryPaymentGateway;
use PHPUnit\Framework\TestCase;

final class PlaceOrderTest extends TestCase
{
    public function test_records_successful_charge(): void
    {
        $gateway = new InMemoryPaymentGateway();
        $orders = new InMemoryOrderRepository();
        $place = new PlaceOrder($gateway, $orders);

        $result = $place->handle(
            orderId: 'order-1',
            email: 'a@b.com',
            amount: new Money(2000, 'EUR'),
            paymentMethodToken: 'pm_card_visa',
        );

        $this->assertSame(ChargeStatus::Succeeded, $result->status);
        $this->assertNotEmpty($orders->find('order-1')->chargeId);
    }

    public function test_marks_order_as_payment_failed_on_decline(): void
    {
        $gateway = new InMemoryPaymentGateway();
        $gateway->chargeWillBeDeclined('insufficient_funds');
        $orders = new InMemoryOrderRepository();
        $place = new PlaceOrder($gateway, $orders);

        $result = $place->handle(
            orderId: 'order-2',
            email: 'a@b.com',
            amount: new Money(2000, 'EUR'),
            paymentMethodToken: 'pm_card_visa',
        );

        $this->assertSame(ChargeStatus::Declined, $result->status);
        $this->assertSame('payment_failed', $orders->find('order-2')->status);
    }
}
Enter fullscreen mode Exit fullscreen mode

No Mockery. No expectations about paymentIntents->create. No fragile assertion that a string equals 'eur' lowercased. The test reads like the checkout policy reads.

A clean import graph: a Checkout box at the top points down to a PaymentGateway interface in the middle, and two boxes below — StripePaymentGateway and InMemoryPaymentGateway — both point up at the interface; the Stripe box has a small Stripe-style logo silhouette stuck to its right edge, the InMemory box has a small in-memory chip silhouette on its left.

The one test that does hit Stripe

The fake is fast. The fake is also a lie — it does whatever you programmed it to do, which means it will happily agree that paymentIntents->create exists even after Stripe renames it.

The fix is a contract test. One PHPUnit test file, run separately from the unit suite (tag it, gate it on a CI env var, or stick it behind make test-contract). It instantiates the real StripePaymentGateway against Stripe's published test mode and walks the port's promises.

<?php
declare(strict_types=1);

use App\Payments\Domain\ChargeRequest;
use App\Payments\Domain\ChargeStatus;
use App\Payments\Domain\Money;
use App\Payments\Domain\PaymentGateway;
use App\Payments\Infrastructure\InMemory\InMemoryPaymentGateway;
use App\Payments\Infrastructure\Stripe\StripePaymentGateway;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
use Stripe\StripeClient;

final class PaymentGatewayContractTest extends TestCase
{
    public static function gateways(): array
    {
        $stripeKey = getenv('STRIPE_TEST_KEY');
        $cases = [
            'in_memory' => [new InMemoryPaymentGateway()],
        ];
        if ($stripeKey !== false && $stripeKey !== '') {
            $cases['stripe_sandbox'] = [
                new StripePaymentGateway(new StripeClient($stripeKey)),
            ];
        }
        return $cases;
    }

    #[DataProvider('gateways')]
    public function test_succeeds_for_known_good_card(PaymentGateway $gw): void
    {
        $req = new ChargeRequest(
            orderId: 'contract-' . uniqid('', true),
            customerEmail: 'contract@example.com',
            amount: new Money(2000, 'EUR'),
            paymentMethodToken: 'pm_card_visa',
            idempotencyKey: bin2hex(random_bytes(16)),
        );

        $result = $gw->charge($req);

        $this->assertSame(ChargeStatus::Succeeded, $result->status);
        $this->assertNotEmpty($result->chargeId);
    }

    #[DataProvider('gateways')]
    public function test_is_idempotent_under_same_key(PaymentGateway $gw): void
    {
        $key = bin2hex(random_bytes(16));
        $req = new ChargeRequest(
            orderId: 'contract-' . uniqid('', true),
            customerEmail: 'contract@example.com',
            amount: new Money(500, 'EUR'),
            paymentMethodToken: 'pm_card_visa',
            idempotencyKey: $key,
        );

        $a = $gw->charge($req);
        $b = $gw->charge($req);

        $this->assertSame($a->chargeId, $b->chargeId);
    }

    #[DataProvider('gateways')]
    public function test_declines_visa_chargeback_token(PaymentGateway $gw): void
    {
        if ($gw instanceof InMemoryPaymentGateway) {
            $gw->chargeWillBeDeclined('generic_decline');
        }
        $req = new ChargeRequest(
            orderId: 'contract-' . uniqid('', true),
            customerEmail: 'contract@example.com',
            amount: new Money(2000, 'EUR'),
            paymentMethodToken: 'pm_card_chargeDeclined',
            idempotencyKey: bin2hex(random_bytes(16)),
        );

        $result = $gw->charge($req);

        $this->assertSame(ChargeStatus::Declined, $result->status);
    }
}
Enter fullscreen mode Exit fullscreen mode

The contract suite walks the port's promises against both adapters. A known-good test card returns Succeeded. Two calls with the same idempotency key return the same chargeId. A known-decline token returns Declined, not an exception.

If Stripe ever changes the shape of paymentIntents->create, the first test fails the next time the contract suite runs. If Stripe ever changes how idempotency keys work, the second fails. If Stripe changes which cards decline by default in test mode, the third fails. You get a red CI build on the day reality drifts, not the day a customer's card stops working.

The contract suite is slow, hits the network, and costs nothing on Stripe's side. Run it on every merge to main, on a nightly cron, and any time you upgrade the Stripe SDK. Not on every commit.

What this buys you

Swapping providers becomes a half-day job, not a quarter. Add an AdyenPaymentGateway that implements the same interface, register it in the container, run the contract suite against it. The rest of the app does not change.

Unit tests stop being SDK trivia quizzes. They test what your checkout does when a charge succeeds or declines, not whether you remembered the right argument names. Refactors stay green.

And the day Stripe ships a breaking change, exactly one file moves: the adapter. Every order policy, every CLI command, every webhook handler upstream of the port keeps compiling.

The "don't mock what you don't own" rule is old. Steve Freeman and Nat Pryce wrote it down in Growing Object-Oriented Software, Guided by Tests almost twenty years ago. It survives because the failure mode it prevents is the worst kind of failure a test can have: a green test suite over a broken integration.

Wrap the SDK. Mock the port. Contract-test the adapter. Then go write a checkout that does not care which payment processor signed the contract.


If this was useful

Decoupling payments from your framework is one slice of a bigger move — pushing Laravel, Symfony, Doctrine, and every third-party SDK to the edge so the application keeps running when the framework underneath it doesn't. The book walks the same ports-and-adapters shape from a hello-world use case up to a multi-adapter production service, with the testing strategy, error translation, and migration playbooks for getting a legacy Laravel or Symfony app there incrementally.

Decoupled PHP — Clean and Hexagonal Architecture for Applications That Outlive the Framework

Available on Kindle, Paperback, and Hardcover. English, German, and Japanese editions out now — Portuguese and Spanish coming soon.

Top comments (0)