Multi-Channel Payments with Strategy Pattern

· 3 min read

Handling multiple payment gateways (Stripe, PayPal, Momo) often leads to messy conditional code:

if ($type === 'stripe') {
    // stripe logic
} elseif ($type === 'paypal') {
    // paypal logic
}

The Strategy Pattern solves this by defining a family of algorithms (payment methods), encapsulating each one, and making them interchangeable.

1. The Interface

Define a contract that all payment gateways must follow.

namespace App\Contracts;

interface PaymentGateway
{
    public function charge(float $amount, array $details): string; // Returns Transaction ID
    public function refund(string $transactionId): bool;
}

2. Concrete Strategies

Implement the interface for each provider.

Stripe Strategy

namespace App\Services\Payments;

use App\Contracts\PaymentGateway;
use Stripe\StripeClient;

class StripePayment implements PaymentGateway
{
    protected $stripe;

    public function __construct() {
        $this->stripe = new StripeClient(config('services.stripe.secret'));
    }

    public function charge(float $amount, array $details): string
    {
        $charge = $this->stripe->charges->create([
            'amount' => $amount * 100, // Cents
            'currency' => 'usd',
            'source' => $details['token'],
        ]);

        return $charge->id;
    }

    public function refund(string $transactionId): bool
    {
        // Implementation
        return true;
    }
}

PayPal Strategy

namespace App\Services\Payments;

use App\Contracts\PaymentGateway;

class PaypalPayment implements PaymentGateway
{
    public function charge(float $amount, array $details): string
    {
        // PayPal specific logic
        return 'paypal_txn_id';
    }

    public function refund(string $transactionId): bool
    {
        return true;
    }
}

3. The Context (PaymentManager)

We need a way to choose the correct strategy at runtime. Laravel's Manager pattern (like Cache::driver()) is perfect for this.

namespace App\Services\Payments;

use Illuminate\Support\Manager;

class PaymentManager extends Manager
{
    public function getDefaultDriver()
    {
        return config('payment.default');
    }

    protected function createStripeDriver()
    {
        return new StripePayment();
    }

    protected function createPaypalDriver()
    {
        return new PaypalPayment();
    }
}

Register this in a Service Provider:

// AppServiceProvider
$this->app->singleton(PaymentManager::class, function ($app) {
    return new PaymentManager($app);
});

4. Usage

Now, switching gateways is elegant.

use App\Services\Payments\PaymentManager;

class CheckoutController extends Controller
{
    public function store(Request $request, PaymentManager $payment)
    {
        // Get driver based on user selection, e.g., 'stripe' or 'paypal'
        $gateway = $payment->driver($request->payment_method);

        try {
            $transactionId = $gateway->charge(100.00, $request->all());
            
            Order::create(['transaction_id' => $transactionId]);
            
            return response()->json(['status' => 'success']);
        } catch (\Exception $e) {
            return response()->json(['error' => 'Payment failed'], 422);
        }
    }
}

Factory Alternative

If you don't want to use the full Manager class, a simple Factory works too:

class PaymentFactory
{
    public static function make(string $type): PaymentGateway
    {
        return match ($type) {
            'stripe' => app(StripePayment::class),
            'paypal' => app(PaypalPayment::class),
            default => throw new \Exception("Unknown payment method"),
        };
    }
}

Handling Different Input Requirements

Stripe needs a token, PayPal might need a return URL. How do we standardize charge($amount, $details)?

Use a Data Transfer Object (DTO) instead of an array.

class PaymentData
{
    public function __construct(
        public float $amount,
        public ?string $token = null,
        public ?string $payerId = null,
    ) {}
}

Update interface to strict type: charge(PaymentData $data).

Benefits

  1. Open/Closed Principle: Add new gateways (Momo, ZaloPay) by adding a new class, not changing existing code.
  2. Testability: You can easily mock PaymentGateway interface in tests.
  3. Clarity: Logic for each provider is isolated in its own file.

Summary

The Strategy Pattern is essentially "Polymorphism" applied to algorithms. In Laravel, combined with the Service Container, it provides a clean, extensive way to manage multiple implementations of the same business capability—like payments, shipping calculations, or notification channels.

Comments