Almost every e-commerce or SaaS backend starts with a single payment gateway and a single shipping carrier. A developer writes a quick if ($gateway === 'stripe') check inside CheckoutService, ships the feature, and moves on. Six months later, the business expands: now you need PayPal, JazzCash, Safepay, and Cash on Delivery (COD) for payments, plus DHL, TCS, and Leopards for courier booking. Suddenly, CheckoutService is a 900-line tangle of nested if/else and switch blocks where a bug in one courier's API payload breaks checkout for every customer. By pairing the Strategy Pattern with the Factory Pattern, you can isolate every third-party integration behind a strict contract so adding a new provider requires zero edits to your core checkout flow.
Why if/else Provider Switching Breaks in Production
When our team at Loop Origin builds or refactors high-volume online stores—especially multi-gateway regional storefronts like those discussed in my guide on launching an online store in Pakistan (platforms, payments, COD, and hosting)—the biggest source of checkout regressions is provider logic leaking directly into controllers and order services.
Here is what the classic anti-pattern looks like before refactoring:
// ANTI-PATTERN: Violates Open/Closed Principle & Single Responsibility
class OrderFulfillmentService
{
public function chargeAndDispatch(Order $order, string $gateway, string $courier): void
{
if ($gateway === 'stripe') {
// 40 lines of Stripe PaymentIntent SDK calls & currency conversion
} elseif ($gateway === 'safepay') {
// 35 lines of HMAC signature generation & checkout session creation
} elseif ($gateway === 'cod') {
// 20 lines of COD verification & phone OTP check
} else {
throw new InvalidArgumentException("Unsupported gateway");
}
if ($courier === 'dhl') {
// 50 lines of DHL XML/JSON airwaybill generation
} elseif ($courier === 'tcs') {
// 45 lines of TCS domestic consignment booking
}
}
}
Why does this become dangerous as transaction volume grows?
- Every New Integration Risks Breaking Existing Gateways: Editing a 900-line file to add a fourth payment method means touching the exact same method that processes your existing Stripe and COD orders.
- Impossible to Unit Test in Isolation: To test a single courier's weight calculation, your test harness has to instantiate a monolithic service coupled to five different SDKs.
-
Inconsistent Error and Return Types: Without a strict interface, one developer returns an associative array
['status' => 'ok', 'id' => '...'], another returns a raw Guzzle response, and a third throws an untyped exception.
How Strategy and Factory Work Together
People often ask whether they should use the Strategy Pattern or the Factory Pattern for third-party integrations. In production backend code, you use both together:
-
The Strategy Pattern defines a uniform Contract (Interface) and puts each provider's implementation inside its own self-contained class (
StripePaymentStrategy,SafepayPaymentStrategy,CodPaymentStrategy). -
The Factory Pattern decides which concrete Strategy class to instantiate based on a runtime key (such as
$order->payment_method, user country, or tenant configuration) and hands it back typed as the interface.
Comparison Table: Hardcoded Conditionals vs. Strategy + Factory Architecture
| Architectural Dimension | Hardcoded if/else / switch in Service |
Strategy Pattern + Factory Resolver |
|---|---|---|
| Adding a New Provider | Edit core CheckoutService and WebhookController in 4+ places |
Create 1 new class implementing PaymentGatewayInterface and register key in Factory map |
| Return Payload Consistency | Arbitrary arrays or raw SDK objects per branch | Enforced immutable Value Object / DTO (ChargeResult, ShipmentLabel) |
| Unit Testing | Requires mocking 5 SDKs inside one giant service test | Each provider strategy is tested in complete isolation; core checkout tests use a FakePaymentGateway
|
| Runtime Fallback / Failover | Deeply nested try/catch blocks inside the controller |
Factory or Decorator can transparently swap to a backup courier strategy if primary API times out |
| Blast Radius of API Changes | High—touching shared checkout class risks all payment flows | Zero—changes to TCS API stay strictly inside TcsCourierStrategy
|
Step-by-Step Implementation: Payment & Courier Integration in PHP / Laravel
Let's build a clean, production-grade implementation using strict DTOs, Strategy interfaces, Concrete Strategies, and a container-backed Factory.
1. Define Immutable Data Transfer Objects (DTOs) and the Strategy Interface
Never return loose associative arrays from a payment or courier driver. Define a readonly DTO so static analysis (PHPStan / Psalm) and IDE autocomplete guarantee every field exists:
namespace App\Domain\Payments\Data;
final readonly class ChargeResult
{
public function __construct(
public bool $successful,
public string $providerReference,
public int $amountMinorUnits,
public string $currency,
public ?string $redirectUrl = null,
) {}
}
Now define the Strategy contract that every payment provider must obey:
namespace App\Domain\Payments\Contracts;
use App\Domain\Payments\Data\ChargeResult;
use App\Models\Order;
interface PaymentGatewayStrategy
{
public function charge(Order $order, string $idempotencyKey): ChargeResult;
public function refund(string $providerReference, int $amountMinorUnits): bool;
public function verifyWebhookSignature(string $rawPayload, array $headers): bool;
}
2. Implement Concrete Strategies for Each Provider
Each provider lives in its own focused class with its own constructor dependencies:
namespace App\Domain\Payments\Strategies;
use App\Domain\Payments\Contracts\PaymentGatewayStrategy;
use App\Domain\Payments\Data\ChargeResult;
use App\Models\Order;
use Stripe\StripeClient;
final class StripeGatewayStrategy implements PaymentGatewayStrategy
{
public function __construct(private readonly StripeClient $stripe) {}
public function charge(Order $order, string $idempotencyKey): ChargeResult
{
$intent = $this->stripe->paymentIntents->create([
'amount' => $order->total_cents,
'currency' => strtolower($order->currency),
'metadata' => ['order_id' => $order->id],
], ['idempotency_key' => $idempotencyKey]);
return new ChargeResult(
successful: in_array($intent->status, ['succeeded', 'requires_capture'], true),
providerReference: $intent->id,
amountMinorUnits: $order->total_cents,
currency: $order->currency,
redirectUrl: $intent->next_action?->redirect_to_url?->url,
);
}
public function refund(string $providerReference, int $amountMinorUnits): bool
{
$refund = $this->stripe->refunds->create([
'payment_intent' => $providerReference,
'amount' => $amountMinorUnits,
]);
return $refund->status === 'succeeded';
}
public function verifyWebhookSignature(string $rawPayload, array $headers): bool
{
// Verify Stripe-Signature header using HMAC
return true;
}
}
3. Build the Factory Using the Dependency Injection Container
Instead of instantiating classes with new inside a switch, map provider keys to class names and let Laravel's IoC container (or Symfony/NestJS DI) inject each strategy's dependencies:
namespace App\Domain\Payments;
use App\Domain\Payments\Contracts\PaymentGatewayStrategy;
use App\Domain\Payments\Strategies\CodGatewayStrategy;
use App\Domain\Payments\Strategies\SafepayGatewayStrategy;
use App\Domain\Payments\Strategies\StripeGatewayStrategy;
use Illuminate\Contracts\Container\Container;
use InvalidArgumentException;
final class PaymentGatewayFactory
{
/** @var array<string, class-string<PaymentGatewayStrategy>> */
private const STRATEGIES = [
'stripe' => StripeGatewayStrategy::class,
'safepay' => SafepayGatewayStrategy::class,
'cod' => CodGatewayStrategy::class,
];
public function __construct(private readonly Container $container) {}
public function make(string $driver): PaymentGatewayStrategy
{
$strategyClass = self::STRATEGIES[strtolower($driver)] ?? null;
if ($strategyClass === null) {
throw new InvalidArgumentException("Unsupported payment driver: {$driver}");
}
return $this->container->make($strategyClass);
}
}
4. Clean Checkout Orchestration (Zero Conditionals)
Look at CheckoutService now—it has zero knowledge of Stripe, Safepay, DHL, or TCS internals. And when you combine this clean service boundary with atomic inventory holds (as detailed in my guide on how to prevent double booking with locks, holds, and expiry), your checkout flow stays rock-solid under high concurrency:
final class CheckoutService
{
public function __construct(
private readonly PaymentGatewayFactory $payments,
private readonly CourierStrategyFactory $couriers,
) {}
public function processOrder(Order $order): ChargeResult
{
$gateway = $this->payments->make($order->payment_driver);
$result = $gateway->charge($order, idempotencyKey: "order_{$order->id}_charge");
if ($result->successful) {
$courier = $this->couriers->make($order->courier_driver);
$shipment = $courier->bookShipment($order);
$order->markFulfilled($result->providerReference, $shipment->trackingNumber);
}
return $result;
}
}
6-Step Architecture Checklist for Payment & Courier Integrations
Before you integrate your next payment gateway or logistics API, run through this checklist:
- [ ] Ban Raw SDK Objects Above the Strategy Boundary: Never return a
Stripe\PaymentIntentor a carrier-specific XML object to your controller; map every response into an application-owned DTO (ChargeResult,ShipmentResult). - [ ] Pass an Explicit
idempotencyKeyin the Strategy Contract: Make idempotency a required parameter oncharge()andbookShipment()so network retries never double-charge a card or generate duplicate airwaybills. - [ ] Use Minor Currency Units (
intCents/Paisa) Everywhere: Never pass floating-pointfloatamounts (19.99) across strategy interfaces; use integer minor units (1999) paired with an ISO 4217 currency code (USD,PKR,GBP). - [ ] Route Webhooks Through the Same Factory: Use
PaymentGatewayFactory::make($provider)in a unified/webhooks/payments/{provider}endpoint so signature verification and status normalization follow the same pattern. - [ ] Write a Contract Test Suite Shared by All Strategies: Create an abstract test trait that verifies every registered strategy handles timeouts, invalid credentials, and idempotent retries consistently.
- [ ] Keep Core Domain Logic Provider-Agnostic: If you are scaling a multi-country or multi-courier storefront and want an architecture built to last, see how our engineering team designs e-commerce backends at Loop Origin.
Frequently Asked Questions (FAQ)
What is the difference between the Strategy Pattern and the Factory Pattern?
The Strategy Pattern is a behavioral pattern that defines a family of interchangeable algorithms (such as charging via Stripe vs. charging via PayPal) behind a common interface. The Factory Pattern is a creational pattern whose job is to construct and return the right Strategy instance based on a runtime value (such as $order->payment_method).
How do I handle a provider that requires extra fields other providers don't need?
Keep the core PaymentGatewayStrategy::charge(Order $order, string $idempotencyKey) signature uniform across all providers, and encapsulate provider-specific metadata inside either a typed value object on the Order context or inside the concrete strategy itself (for example, CodGatewayStrategy can inspect $order->shippingAddress->phone without polluting the method arguments for Stripe).
Should I use a Factory class or Laravel's built-in Manager pattern?
Laravel's Illuminate\Support\Manager class (used internally by Cache, Storage, and Mail) is a battle-tested implementation of the Factory + Strategy pattern. Writing a dedicated PaymentGatewayFactory backed by a const STRATEGIES map is slightly lighter and framework-agnostic, while extending Manager is great if you want Payment::driver('stripe') facade syntax.
How do I handle automatic failover if a courier API is down?
Because every courier implements CourierStrategy, you can wrap two strategies inside a FailoverCourierStrategy (using the Decorator/Chain pattern) that attempts primary->bookShipment($order) and automatically falls back to secondary->bookShipment($order) on a 5xx or connection timeout.
How do I unit test CheckoutService without hitting real Stripe or DHL APIs?
Bind an in-memory FakePaymentStrategy and FakeCourierStrategy in your test container. Your CheckoutService tests will run in milliseconds without network calls, while each concrete strategy has its own integration test suite against mocked HTTP fixtures.

Top comments (0)