Mastering Design Patterns in Laravel: Implementing the Adapter Pattern for Scalable Payment Gateways

Share
Mastering Design Patterns in Laravel: Implementing the Adapter Pattern for Scalable Payment Gateways

Executive Overview

In the fast-paced ecosystem of modern web development, building applications that can gracefully scale and effortlessly integrate third-party services is paramount. For PHP developers leveraging the Laravel framework, maintaining clean architecture often separates a robust, enterprise-ready application from a fragile, unmaintainable monolith. As applications grow, they frequently encounter a classic architectural friction point: the integration of disparate external APIs, SDKs, and libraries that possess incompatible interfaces.

When developers hardcode these external dependencies directly into controllers or domain layers, they violate core software engineering principles, leading to tightly coupled codebases that are notoriously difficult to test, maintain, and extend. This comprehensive guide explores one of the most powerful structural design patterns available to the modern developer—the Adapter Pattern.

By contextualizing this pattern through a real-world payment processing scenario involving both Stripe and Square, this article will demonstrate how to refactor an anti-pattern codebase into a decoupled, SOLID-compliant, and highly extensible Laravel application. Whether you are transitioning legacy applications or architecting new cloud-native microservices, mastering the Adapter pattern is an essential milestone in your architectural evolution.


Detailed Chronology: The Evolution of Software Integration and Anti-Patterns

To truly appreciate the utility of the Adapter pattern, it is instructive to examine the historical trajectory of how developers have integrated third-party services, and why traditional shortcuts inevitably lead to technical debt.

The Era of Direct Coupling

In the early days of web applications, integrating a payment gateway like PayPal or Authorize.Net involved directly invoking procedural functions or instantiating SDK classes deep within request handlers. As SaaS ecosystems matured, platforms like Stripe and Square revolutionized developer experience by providing robust, object-oriented SDKs. However, the convenience of these SDKs introduced a new architectural hazard: developers began treating third-party client objects as core domain components.

Without a unifying contract or interface, controllers quickly swelled with conditional logic (if/else or switch statements) to handle distinct payload structures, authentication headers, and response formats for each provider.

The Rise of Modern Design Patterns in PHP

With the advent of modern PHP (PHP 8.x) and the explosive popularity of frameworks like Laravel, the developer community placed a renewed emphasis on design patterns popularized by the "Gang of Four" (GoF). Architectural paradigms such as Domain-Driven Design (DDD) and SOLID principles shifted the focus away from framework-bound mechanics toward domain purity.

The Adapter pattern emerged as the definitive structural solution for bridging the gap between legacy or third-party contracts and the unified interfaces demanded by domain logic. Rather than rewriting external libraries—an impossibility when dealing with vendor code—developers learned to build protective wrappers that translate foreign interfaces into predictable, application-specific contracts.


Understanding the Adapter Pattern

Definition

In software engineering, the Adapter pattern is a structural design pattern that allows objects with incompatible interfaces to collaborate. It acts as a bridge between two incompatible entities, converting the interface of a class into another interface that a client expects.

The Real-World Analogy

Consider an international traveler flying from the United States to the European Union. Upon arriving at a hotel, the traveler attempts to plug a US laptop charger into a European wall socket. Because the physical configurations and electrical interfaces are fundamentally incompatible, power cannot flow.

To resolve this dilemma, the traveler does not rewrite the hotel’s electrical grid, nor do they modify the laptop’s power supply. Instead, they purchase a travel socket adapter. This adapter exposes a US-compatible female receptacle on one side and a EU-compatible male plug on the other, seamlessly translating the physical connection. In software development, the Adapter pattern performs this exact translational duty for APIs and class libraries.


The Cost of Neglecting Architecture: A Bad Example (Anti-Pattern)

To understand why design patterns are necessary, let us examine a typical anti-pattern implementation. Below is a monolithic Laravel controller handling checkout operations for multiple payment gateways without an adapter layer.

<?php

namespace AppHttpControllers;

use IlluminateHttpRequest;
use StripeStripeClient;
use SquareSquareClient;
use SquareModelsMoney;
use SquareModelsCreatePaymentRequest;

class BadCheckoutController extends Controller

    public function store(Request $request)
    
        // Raw, unprocessed request data extraction introduces downstream vulnerabilities
        $gateway  = $request->string('gateway');
        $amount   = (float) $request->input('amount');
        $currency = $request->input('currency', 'USD');
        $pm       = $request->string('payment_method');

        // Anti-pattern: Hardcoded conditional branching for specific gateway instantiation
        if ($gateway === 'stripe') 
            $stripe = new StripeClient(config('services.stripe.secret'));

            $intent = $stripe->paymentIntents->create([
                'amount'         => (int) round($amount),
                'currency'       => strtolower($currency),
                'payment_method' => $pm,
                'confirm'        => true,
                'description'    => $request->input('description', 'Order #' . now()->timestamp),
            ]);

            if ($intent->status !== 'succeeded') 
                return back()->withErrors(['payment' => 'Stripe failed: ' . $intent->status]);
            

            // Business logic leaking into the HTTP controller layer
            return redirect()->route('thankyou')->with('tx', $intent->id);

         elseif ($gateway === 'square') 
            $square = new SquareClient([
                'accessToken' => config('services.square.access_token'),
                'environment' => config('services.square.environment', 'sandbox'),
            ]);

            $paymentsApi = $square->getPaymentsApi();

            $money = new Money();
            $money->setAmount((int) ($amount * 100.0));
            $money->setCurrency(strtoupper($currency));

            $requestObj = new CreatePaymentRequest(
                sourceId: $pm,
                idempotencyKey: (string) rand(),
                amountMoney: $money
            );

            try 
                $response = $paymentsApi->createPayment($requestObj);
                if ($response->isSuccess()) 
                    $payment = $response->getResult()->getPayment();
                    if ($payment->getStatus() !== 'COMPLETED') 
                        return back()->withErrors(['payment' => 'Square not completed: ' . $payment->getStatus()]);
                    
                    return redirect()->route('thankyou')->with('tx', $payment->getId());
                

                $errs = collect($response->getErrors() ?? [])->map(fn($e) => $e->getDetail() ?: 'error')->implode('; ');
                return back()->withErrors(['payment' => 'Square failed: ' . $errs]);
             catch (Throwable $e) 
                return back()->withErrors(['payment' => 'Square error: ' . $e->getMessage()]);
            
        

        return back()->withErrors(['payment' => 'Unknown gateway']);
    

Critiquing the Anti-Pattern

This code violates multiple fundamental principles of software design:

  1. Single Responsibility Principle (SRP): The controller is responsible for HTTP handling, input validation, payment gateway initialization, SDK-specific data formatting, error handling, and session management.
  2. Open/Closed Principle (OCP): Adding a third gateway (e.g., PayPal or Adyen) requires modifying this controller, increasing the risk of regression bugs.
  3. Tight Coupling: The application core is directly bound to specific vendor SDK classes and exception structures.

Refactoring with the Adapter Pattern: Implementation Guide

To resolve these architectural deficiencies, we will implement a clean, decoupled payment domain within our Laravel application.

Step 1: Define the Unified Contract (Interface)

First, we establish a common interface that all payment gateway adapters must implement. This contract ensures that regardless of whether the underlying system is Stripe, Square, or another provider, our application interacts with them via a standardized method signature.

<?php

namespace AppDomainsPayment;

interface PaymentGateawayInterface

    public function charge(
        int $amount,
        string $currency,
        string $source,
        string $description = ''
    ): ChargeResult;

Step 2: Establish a Value Object for Results

To normalize responses across different SDKs, we introduce a standardized Data Transfer Object (DTO) known as ChargeResult.

<?php

namespace AppDomainsPayment;

class ChargeResult

    public function __construct(
        public bool $success,
        public ?string $transactionId,
        public string $message,
    ) 

Step 3: Implement the Stripe Adapter

Next, we install the official Stripe library via Composer:

$ composer require stripe/stripe-php

We then create the StripeGateawayAdapter class, which wraps the Stripe SDK and translates its responses into our uniform ChargeResult DTO.

<?php

namespace AppDomainsPayment;

use StripeStripeClient;
use Exception;

class StripeGateawayAdapter implements PaymentGateawayInterface

    public function __construct(
        private StripeClient $client
    ) 

    public function charge(
        int $amount,
        string $currency,
        string $source,
        string $description = ''
    ): ChargeResult 
        try 
            $response = $this->client->paymentIntents->create([
                'amount'         => $amount,
                'currency'       => $currency,
                'payment_method' => $source,
                'confirm'        => true,
                'description'    => $description,
            ]);

            return new ChargeResult(
                success: $response->status === 'succeeded',
                transactionId: $response->id,
                message: $response->status
            );
         catch (Exception $e) 
            return new ChargeResult(
                success: false,
                transactionId: null,
                message: $e->getMessage()
            );
        
    

Step 4: Implement the Square Adapter

Similarly, we create the SquareGateawayAdapter to encapsulate the Square SDK’s complex object initialization and error handling routines.

<?php

namespace AppDomainsPayment;

use Exception;
use SquarePaymentsRequestsCreatePaymentRequest;
use SquareLegacyModelsMoney;
use SquareSquareClient;
use IlluminateSupportStr;

class SquareGateawayAdapter implements PaymentGateawayInterface

    public function __construct(
        private SquareClient $client
    ) 

    public function charge(
        int $amount,
        string $currency,
        string $source,
        string $description = ''
    ): ChargeResult 
        $money = new Money();
        $money->setAmount($amount);
        $money->setCurrency(strtoupper($currency));

        $request = new CreatePaymentRequest([
            'idempotencyKey' => Str::uuid()->toString(),
            'sourceId'       => $source,
            'amountMoney'    => $money,
        ]);

        if ($description !== '') 
            $request->setNote($description);
        

        try 
            $response = $this->client->payments->create($request);

            if ($payment = $response->getPayment()) 
                return new ChargeResult(
                    success: $payment->getStatus() === 'COMPLETED',
                    transactionId: $payment->getId(),
                    message: $payment->getStatus()
                );
            

            return new ChargeResult(
                success: false,
                transactionId: null,
                message: 'Unknown payment response from Square.'
            );
         catch (Exception $e) 
            return new ChargeResult(
                success: false,
                transactionId: null,
                message: $e->getMessage(),
            );
        
    

Supporting Context & Metrics: Architectural Comparison

To quantify the benefits of implementing the Adapter pattern, enterprise software engineering teams frequently evaluate codebases across several key metrics:

Metric Anti-Pattern Implementation Adapter Pattern Implementation
Cyclomatic Complexity High (Deep nested conditionals) Low (Polymorphic method calls)
Testability Difficult (Requires complex mocking of vendor SDKs) High (Easily mockable via PaymentGateawayInterface)
Maintainability Index Low High
Time to Add New Gateway High risk of breaking changes Low (Create new adapter class and register binding)
Adherence to SOLID Violates SRP, OCP, and DIP Fully compliant with SOLID principles

Container Binding in Laravel

To leverage Laravel’s powerful service container for dependency injection and dynamic resolution, we configure our bindings within AppServiceProvider.php.

<?php

namespace AppProviders;

use AppDomainsPaymentPaymentGateawayInterface;
use AppDomainsPaymentSquareGateawayAdapter;
use AppDomainsPaymentStripeGateawayAdapter;
use IlluminateSupportServiceProvider;

class AppServiceProvider extends ServiceProvider

    public function register(): void
    
        // Bindings can be dynamically configured or abstracted via factory classes
        $this->app->bind(PaymentGateawayInterface::class, function ($app) 
            $driver = config('payments.driver', 'stripe');

            return match ($driver) 
                'stripe' => new StripeGateawayAdapter(
                    new StripeStripeClient(config('services.stripe.secret'))
                ),
                'square' => new SquareGateawayAdapter(
                    new SquareSquareClient([
                        'accessToken' => config('services.square.access_token'),
                        'environment' => config('services.square.environment', 'sandbox'),
                    ])
                ),
                default => throw new RuntimeException("Unknown payment driver [$driver]"),
            ;
        );
    

    public function boot(): void
    
        //
    

The Clean Controller

With our adapters and container bindings properly established, our checkout controller is reduced to a clean, highly readable state. It is entirely agnostic of whether Stripe or Square is processing the underlying transaction.

<?php

namespace AppHttpControllers;

use IlluminateHttpRequest;
use AppDomainsPaymentPaymentGateawayInterface;

class CheckoutController extends Controller

    public function __construct(
        private readonly PaymentGateawayInterface $gateway
    ) 

    public function store(Request $request)
    
        $result = $this->gateway->charge(
            amount: (int) ($request->input('amount') * 100),
            currency: $request->input('currency', 'USD'),
            source: $request->string('payment_method'),
            description: 'Order #' . now()->timestamp
        );

        if (! $result->success) 
            return back()->withErrors(['payment' => $result->message]);
        

        return redirect()
            ->route('thankyou')
            ->with('tx', $result->transactionId);
    

Official Statements and Industry Best Practices

Leading software architects and Laravel core contributors consistently advocate for structural decoupling when handling third-party integrations.

"When building enterprise applications, your domain logic should remain pristine and completely isolated from the implementation details of third-party vendors. Design patterns like the Adapter pattern are not academic exercises; they are essential survival tools for managing change in evolving software systems."
Senior Enterprise Architecture Guidelines

According to the Laravel documentation and community standards, leveraging service providers, interface binding, and constructor injection ensures that applications remain testable, flexible, and resilient against breaking vendor API updates.


Future Outlook

As cloud infrastructure, payment ecosystems, and microservices architectures continue to evolve, the demand for clean, decoupled code will only intensify. Emerging technologies—such as automated SDK generation tools and AI-assisted refactoring pipelines—still rely on foundational object-oriented design principles to organize code logically.

By adopting the Adapter pattern in Laravel today, development teams future-proof their applications against vendor lock-in, streamline automated testing through robust mocking strategies, and establish a maintainable codebase primed for rapid feature expansion. Embracing these patterns ensures that your applications remain agile, resilient, and ready to meet the demands of tomorrow’s digital landscape.

Did you find this story helpful?

Share it with your friends and colleagues on social media.

Share

Leave a Comment

Your email address will not be published. Required fields are marked *