Laravel OOP in Action: Building a Decoupled Payment Gateway

Laravel OOP in Action: Building a Decoupled Payment Gateway

Why OOP Matters in Laravel

Laravel embraces object-oriented programming, but it's easy to fall into the trap of writing procedural code inside controllers or facades. True OOP in Laravel means leveraging interfaces, dependency injection, and single-responsibility classes to create maintainable, testable systems. Let's explore this by building a payment gateway abstraction that can switch between providers without altering business logic.

The Problem with Tight Coupling

A common anti-pattern is calling a payment provider directly in a controller:

public function checkout(Request $request)
{
    $stripe = new StripeClient(config('services.stripe.secret'));
    $charge = $stripe->charges->create([
        'amount' => $request->amount * 100,
        'currency' => 'usd',
        'source' => $request->stripeToken,
    ]);
    // ...
}

This couples your application to Stripe. Switching to PayPal or adding a mock for tests requires rewriting controller code.

Defining a Contract with Interfaces

Start by defining what any payment gateway must do:

namespace App\Contracts;

interface PaymentGateway
{
    public function charge(int $amount, array $paymentData): array;
    public function refund(string $transactionId, ?int $amount = null): array;
}

Implementing the Interface

Create concrete implementations for each provider.

Stripe Implementation

namespace App\Services\PaymentGateways;

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

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

    public function charge(int $amount, array $paymentData): array
    {
        $charge = $this->stripe->charges->create([
            'amount' => $amount,
            'currency' => 'usd',
            'source' => $paymentData['token'],
        ]);

        return [
            'id' => $charge->id,
            'amount' => $charge->amount,
            'status' => $charge->status,
        ];
    }

    public function refund(string $transactionId, ?int $amount = null): array
    {
        $params = ['charge' => $transactionId];
        if ($amount) {
            $params['amount'] = $amount;
        }
        $refund = $this->stripe->refunds->create($params);

        return [
            'id' => $refund->id,
            'status' => $refund->status,
        ];
    }
}

PayPal Implementation

namespace App\Services\PaymentGateways;

use App\Contracts\PaymentGateway;

class PayPalGateway implements PaymentGateway
{
    public function __construct(private PayPalClient $client) {}

    public function charge(int $amount, array $paymentData): array
    {
        $order = $this->client->createOrder([
            'intent' => 'CAPTURE',
            'purchase_units' => [
                ['amount' => ['currency_code' => 'USD', 'value' => $amount / 100]]
            ]
        ]);

        return [
            'id' => $order->id,
            'amount' => $amount,
            'status' => $order->status,
        ];
    }

    public function refund(string $transactionId, ?int $amount = null): array
    {
        $refund = $this->client->refundCapture($transactionId, $amount ? ['amount' => $amount / 100] : []);

        return [
            'id' => $refund->id,
            'status' => $refund->status,
        ];
    }
}

Binding the Interface via Service Provider

Register the default gateway in a service provider:

namespace App\Providers;

use Illuminate\Support\ServiceProvider;
use App\Contracts\PaymentGateway;
use App\Services\PaymentGateways\StripeGateway;

class PaymentServiceProvider extends ServiceProvider
{
    public function register(): void
    {
        $this->app->bind(PaymentGateway::class, function ($app) {
            $gateway = config('payment.default');

            return match ($gateway) {
                'stripe' => new StripeGateway(
                    new StripeClient(config('services.stripe.secret'))
                ),
                'paypal' => new PayPalGateway(
                    new PayPalClient(config('services.paypal.client_id'), config('services.paypal.secret'))
                ),
                default => throw new \RuntimeException("Unknown payment gateway: {$gateway}"),
            };
        });
    }
}

Using the Gateway in a Controller

Now the controller depends on the interface, not a concrete class:

namespace App\Http\Controllers;

use App\Contracts\PaymentGateway;
use Illuminate\Http\Request;

class CheckoutController extends Controller
{
    public function __construct(private PaymentGateway $gateway) {}

    public function process(Request $request)
    {
        $charge = $this->gateway->charge(
            $request->amount * 100,
            ['token' => $request->stripeToken]
        );

        // Business logic...
    }
}

Testing with a Fake Gateway

For tests, swap the implementation with a fake:

namespace App\Services\PaymentGateways;

use App\Contracts\PaymentGateway;

class FakeGateway implements PaymentGateway
{
    public array $charges = [];
    public array $refunds = [];

    public function charge(int $amount, array $paymentData): array
    {
        $this->charges[] = ['amount' => $amount, 'data' => $paymentData];
        return ['id' => 'fake_charge_123', 'amount' => $amount, 'status' => 'succeeded'];
    }

    public function refund(string $transactionId, ?int $amount = null): array
    {
        $this->refunds[] = ['transaction_id' => $transactionId, 'amount' => $amount];
        return ['id' => 'fake_refund_456', 'status' => 'succeeded'];
    }
}

In a test, bind the fake:

public function test_checkout_charges_correct_amount()
{
    $fake = new FakeGateway();
    $this->app->instance(PaymentGateway::class, $fake);

    $response = $this->post('/checkout', ['amount' => 50, 'stripeToken' => 'tok_visa']);

    $response->assertStatus(200);
    $this->assertCount(1, $fake->charges);
    $this->assertEquals(5000, $fake->charges[0]['amount']);
}

Benefits of This Approach

  • Decoupling: Controllers and services don't know which gateway is used.
  • Testability: Fakes allow unit testing without external APIs.
  • Extensibility: Adding a new gateway means writing a new class and updating the service provider.
  • Adherence to SOLID: Interface segregation, dependency inversion, and single responsibility are respected.

Conclusion

Object-oriented programming in Laravel isn't about using classes for the sake of it; it's about designing systems that are resilient to change. By leveraging interfaces, dependency injection, and service providers, you can build applications that are a pleasure to maintain and extend. The next time you reach for a facade or a static call, ask yourself: could this be an interface instead?