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?