DEV Community

Cover image for Laravel & Design Patterns — Practice Series: Adapter
Demian Kostelny
Demian Kostelny

Posted on

Laravel & Design Patterns — Practice Series: Adapter

Welcome back. This is the next article in our practical use of patterns in Laravel, and in this guide, we are going to talk about one of the most popular patterns that is used by many developers — the Adapter pattern.

Introduction

Let’s begin with the definition of Adapter:

Adapter pattern is a structural design pattern that allows objects with incompatible interfaces to work together by translating one interface into another that a client expects.

Real-world example

Imagine that you’re traveling from the US to the EU, and when you arrive at your hotel, you find out that in the EU, people use a different type of socket to use their electronic devices. And to solve this problem, you buy a socket adapter to easily charge your devices with your US plug.

Bad example (anti-pattern)

First, let’s see what a bad solution looks like without using any adapter classes and without any clean code architecture:

<?php

namespace App\Http\Controllers;

use Illuminate\Http\Request;
use Stripe\StripeClient;
use Square\SquareClient;
use Square\Models\Money;
use Square\Models\CreatePaymentRequest;

class BadCheckoutController extends Controller
{
    public function store(Request $request)
    {
        // We get a lot of data from $request without any processing, which can create
        // a lot of issues in the future for transactions processing
        $gateway = $request->string('gateway');
        $amount  = (float) $request->input('amount');
        $currency = $request->input('currency', 'USD');
        $pm = $request->string('payment_method');

        // Another bad practice - is to use conditions for creating new class objects with hardcoded actions
        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]);
            }

            // All this business logic in controller will create another issues
            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']);
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Looks horrible 0.0 Now it’s time to separate all logic properly with best practices, and to solve it — we are going to use the Adapter pattern.

Adapter pattern implementation

First of all, we need to define an interface that will be used by our adapter classes to properly handle different payment systems with the same function:

<?php

namespace App\Domains\Payment;

interface PaymentGateawayInterface
{
    public function charge(
        int $amount,
        string $currency,
        string $source,
        string $description = ''       
    );
}
Enter fullscreen mode Exit fullscreen mode

Also, don’t forget to install the payment library with Composer into the Laravel project:

$ composer require stripe/stripe-php
Enter fullscreen mode Exit fullscreen mode

After dependencies are installed, we also need to define a class where we are going to store transaction results

<?php

namespace App\Domains\Payment;

class ChargeResult
{
    public function __construct(
        public bool $success,
        public string $transactionId,
        public string $message,
    ) {}
}
Enter fullscreen mode Exit fullscreen mode

Now that the main components are defined, it’s time to create our first adapter to handle Stripe transactions:

<?php

namespace App\Domains\Payment;

use Stripe\StripeClient;
use Exception;

class StripeGateawayAdapter implements PaymentGateawayInterface
{
    // We use dependency injection to properly handle
    // object creation from the framework side
    public function __construct(
        private StripeClient $client
    ) {}

    public function charge(
        int $amount,
        string $currency,
        string $source,
        string $description = ''
    ) {
        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()
            );
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

But that's not the only adapter class that we need to create; we will also create an adapter to handle Square payment system transactions:

<?php

namespace App\Domains\Payment;

use Exception;
use Square\Payments\Requests\CreatePaymentRequest;
use Square\Legacy\Models\Money;
use Square\SquareClient;
use Illuminate\Support\Str;

class SquareGateawayAdapter implements PaymentGateawayInterface
{
    public function __construct(
        private SquareClient $client
    ) {}

    public function charge(
        int $amount,
        string $currency,
        string $source,
        string $description = ''
    ) {
        $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 ( $response->getPayment() ) {
                // ... return success here
            }
        } catch ( Exception $e ) {
            return new ChargeResult(
                success: false,
                transactionId: null,
                message: $e->getMessage(),
            );
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

That looks much better now with using adapters; our code is now cleaner, readable, and we follow SOLID principles with OOP. And of course, if we are using any interface in the Laravel framework, we also need to bind it properly in the AppServiceProvider.php:

<?php

namespace App\Providers;

use App\Domains\Payment\PaymentGateawayInterface;
use App\Domains\Payment\SquareGateawayAdapter;
use App\Domains\Payment\StripeGateawayAdapter;
use Illuminate\Support\ServiceProvider;
use App\Domains\Report\Builders\IReportBuilder;
use App\Domains\Report\Builders\ReportBuilder;

class AppServiceProvider extends ServiceProvider
{
    /**
     * Register any application services.
     */
    public function register(): void
    {
        $this->app->bind(PaymentGateawayInterface::class, StripeGateawayAdapter::class);
        $this->app->bind(PaymentGateawayInterface::class, SquareGateawayAdapter::class);
    }

    /**
     * Bootstrap any application services.
     */
    public function boot(): void
    {
        //
    }
}
Enter fullscreen mode Exit fullscreen mode

Amazing, now we can easily use it in our controllers:

<?php

namespace App\Http\Controllers;

use Illuminate\Http\Request;
use App\Domains\Payment\StripeGateawayAdapter;
use App\Domains\Payment\SquareGateawayAdapter;
use App\Domains\Payment\PaymentGateawayInterface;
use Stripe\StripeClient;
use Square\SquareClient;


class CheckoutController extends Controller
{
    private PaymentGateawayInterface $gateway;

    public function __construct()
    {
        // We will have config for all payments
        $driver = config('payments.driver', 'stripe');

        // Dynamically switch gateaway payment based on config
        // we can also move this code to service file
        $this->gateway = match ($driver) {
            'stripe' => new StripeGateawayAdapter(
                new StripeClient(config('services.stripe.secret'))
            ),
            'square' => new SquareGateawayAdapter(
                new SquareClient([
                    'accessToken' => config('services.square.access_token'),
                    'environment' => config('services.square.environment'),
                ])
            ),
            default => throw new \RuntimeException("Unknown payment driver [$driver]"),
        };
    }

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

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

        return redirect()
            ->route('thankyou')
            ->with('tx', $result->transactionId);
    }
}
Enter fullscreen mode Exit fullscreen mode

Conclusion

Now our code is much cleaner and readable, and of course, we follow SOLID principles. Remember, in cases when you have any external library or classes that are not compatible with your interface, you can use the Adapter pattern to easily implement them into your code and separate all logic properly. Thanks for reading!

Top comments (0)