GeekFolks

Plug-and-Play Payments in Laravel: Interfaces, Factory & Tagging (bKash, Nagad, Rocket, EBL, SCB & more)

Md Nasir WahidMd Nasir Wahid
Updated August 19, 20262 min readSoftware delivery and engineering practice
Plug-and-play payment gateway architecture diagram

TL;DR: Stop hard-coding payment logic. Define a PaymentGateway interface, implement one class per gateway, tag them in the container, and select the right one at runtime via a tiny factory. This makes your checkout flexible, testable, and easy to extend.

Who is this for? Laravel engineers integrating multiple Bangladeshi/mobile wallets and bank gateways (bKash, Nagad, Rocket, EBL, SCB, …) and anyone who wants clean architecture for payments.

Why interfaces for payments?

  • Runtime choice: Users pick the gateway; the backend cleanly routes to it.
  • Swap anytime: Replace a provider without touching business logic.
  • Testability: Mock/fake a gateway in one line.
  • Scale: Add new providers with zero churn to existing flows.

The architecture (in one glance)

plaintext
[Controller] -> [PaymentGatewayFactory] -> [Concrete Gateway]                                   ^           ^   ^   ^                                   |         (bKash Nagad EBL SCB ...)                            [Tagged in Container]
  • A single contract (PaymentGateway) defines required methods.
  • Each provider implements that contract.
  • We tag all gateway classes so the factory can discover them.
  • Controller asks the factory for the gateway the user chose (e.g., bkash) and calls charge().

1) Define the contract

app/Contracts/PaymentGateway.php
namespace App\Contracts;interface PaymentGateway{    /** Stable machine ID, e.g., 'bkash', 'nagad' */    public function id(): string;     /** Human label for UI */    public function name(): string;     /** One-shot charge in minor units (BDT paisa, cents, etc.) */    public function charge(int $amount, array $meta = []): string; // return txn id    /** Optional features */    public function refund(string $txnId, int $amount): bool;    public function supportsCurrency(string $currency): bool;}

2) Implement concrete gateways

app/Services/Gateways/BkashGateway.php
namespace App\Services\Gateways;use App\Contracts\PaymentGateway;class BkashGateway implements PaymentGateway{    public function __construct(private array $cfg) {} // config('payments.bkash')    public function id(): string   { return 'bkash'; }    public function name(): string { return 'bKash'; }    public function charge(int $amount, array $meta = []): string    {        // TODO: auth, create & execute payment using $this->cfg        return 'BKASH_TXN_12345';    }    public function refund(string $txnId, int $amount): bool    {        // TODO: call refund API        return true;    }    public function supportsCurrency(string $currency): bool    {        return strtoupper($currency) === 'BDT';    }}

Repeat a small class per provider:

plaintext
app/Services/Gateways/NagadGateway.phpapp/Services/Gateways/RocketGateway.phpapp/Services/Gateways/EblGateway.phpapp/Services/Gateways/ScbGateway.php

Each implements the same methods.

3) Credentials & settings

config/payments.php
return [    'default_currency' => 'BDT',    'bkash' => [        'app_key'    => env('BKASH_APP_KEY'),        'app_secret' => env('BKASH_APP_SECRET'),        'username'   => env('BKASH_USERNAME'),        'password'   => env('BKASH_PASSWORD'),        'sandbox'    => (bool) env('BKASH_SANDBOX', true),    ],    'nagad' => [        'merchant_id' => env('NAGAD_MERCHANT_ID'),        'public_key'  => env('NAGAD_PUBLIC_KEY'),        'private_key' => env('NAGAD_PRIVATE_KEY'),    ],    'rocket' => [/* ... */],    'ebl'    => [/* ... */],    'scb'    => [/* ... */],];

Keep secrets in .env. Your classes read config('payments.*').

4) The factory (runtime selection by ID)

app/Payments/PaymentGatewayFactory.php
namespace App\Payments;use App\Contracts\PaymentGateway;use InvalidArgumentException;class PaymentGatewayFactory{    /** @var array<string, PaymentGateway> */    private array $byId = [];    /**     * @param iterable<PaymentGateway> $gateways  (container-injected via tagging)     */    public function __construct(iterable $gateways)    {        foreach ($gateways as $g) {            $this->byId[$g->id()] = $g;        }    }    public function for(string $id): PaymentGateway    {        $key = strtolower($id);        if (!isset($this->byId[$key])) {            throw new InvalidArgumentException("Unknown gateway: {$id}");        }        return $this->byId[$key];    }    /** For UI dropdowns, etc. */    public function list(): array    {        return array_map(            fn($g) => ['id' => $g->id(), 'name' => $g->name()],            $this->byId        );    }}

5) Wire everything in the container (bind + tag)

app/Providers/AppServiceProvider.php
use App\Payments\PaymentGatewayFactory;use App\Services\Gateways\{BkashGateway, NagadGateway, RocketGateway, EblGateway, ScbGateway}; public function register(): void{    // Bind concrete gateways with their specific configs    $this->app->bind(BkashGateway::class, fn() => new BkashGateway(config('payments.bkash')));    $this->app->bind(NagadGateway::class, fn() => new NagadGateway(config('payments.nagad')));    $this->app->bind(RocketGateway::class, fn() => new RocketGateway(config('payments.rocket')));    $this->app->bind(EblGateway::class,   fn() => new EblGateway(config('payments.ebl')));    $this->app->bind(ScbGateway::class,   fn() => new ScbGateway(config('payments.scb')));    // Tag them so the factory can discover all gateways automatically    $this->app->tag(        [BkashGateway::class, NagadGateway::class, RocketGateway::class, EblGateway::class, ScbGateway::class],        'payment.gateways'    );    // Factory receives all tagged gateways    $this->app->singleton(PaymentGatewayFactory::class, function ($app) {        return new PaymentGatewayFactory($app->tagged('payment.gateways'));    });}

Why tagging? You can add new gateways later by just binding + tagging them. The factory stays unchanged.

6) Use it in your controller

app/Http/Controllers/CheckoutController.php
namespace App\Http\Controllers;use App\Payments\PaymentGatewayFactory;use Illuminate\Http\Request;class CheckoutController extends Controller{    public function __construct(private PaymentGatewayFactory $factory) {}    public function pay(Request $req)    {        $validated = $req->validate([            'gateway' => 'required|string|in:bkash,nagad,rocket,ebl,scb',            'amount'  => 'required|integer|min:1', // minor units            'currency'=> 'sometimes|string|size:3',        ]);        $gateway = $this->factory->for($validated['gateway']);        $currency = strtoupper($validated['currency'] ?? config('payments.default_currency'));        abort_unless($gateway->supportsCurrency($currency), 422, 'Currency not supported by this gateway');        $txnId = $gateway->charge($validated['amount'], [            'order_id' => $req->input('order_id'),            'user_id'  => $req->user()?->id,            'currency' => $currency,        ]);        return response()->json([            'status'  => 'success',            'gateway' => $gateway->id(),            'txn_id'  => $txnId,        ]);    }}

Your frontend simply posts { gateway: 'bkash', amount: 5000 }.

7) Unit testing (fast & clean)

tests/Feature/CheckoutTest.php
use App\Contracts\PaymentGateway;use App\Payments\PaymentGatewayFactory; class FakeGateway implements PaymentGateway {    public function id(): string { return 'bkash'; }    public function name(): string { return 'Fake bKash'; }    public function charge(int $amount, array $meta = []): string { return 'FAKE_TXN'; }    public function refund(string $txnId, int $amount): bool { return true; }    public function supportsCurrency(string $currency): bool { return true; }}public function test_user_can_pay_with_selected_gateway(){    $this->app->singleton(PaymentGatewayFactory::class, function () {        return new PaymentGatewayFactory([ new FakeGateway() ]);    });    $res = $this->postJson('/checkout/pay', ['gateway' => 'bkash', 'amount' => 5000]);    $res->assertOk()->assertJsonPath('txn_id', 'FAKE_TXN');}

Alternatives: Laravel "Manager/Driver" pattern

Prefer the style used by cache() or queue()? Create a PaymentManager extending Illuminate\Support\Manager and implement createBkashDriver(), createNagadDriver(), etc. Then call:

php
Payment::driver('bkash')->charge(5000);

Both patterns are valid; interface + factory + tagging is simpler to onboard and very explicit.

Common pitfalls & tips

  • Minor units: Choose one convention (paisa/cents) and stick to it across all gateways.
  • Idempotency: Protect charge() with idempotency keys to avoid double charges on retries.
  • Webhooks: Record the mapping from your order_id to the gateway txn_id for reconciliation.
  • Feature flags: Roll out gateways gradually (e.g., FEATURE_GATEWAY_BKASH=true).
  • Octane/queues: If you use singletons, don't store per-request state inside them.

Why this pattern matters beyond Laravel

The same interface-and-factory approach is what keeps payment integrations maintainable in production commerce systems — it's the pattern behind reconciling every sale into a ledger automatically in our POS + Accounts & Finance product, and it's the kind of engineering discipline we bring to every custom software development engagement.

For the infrastructure side of running production systems reliably, see How to Auto-Renew SSL Certificates on cPanel with acme.sh, and for the business case behind wiring payments straight to accounting, see why your POS numbers never match your books.

ShareLinkedInX

Get new posts in your inbox

No spam — just new articles as we publish them.

Md Nasir Wahid

Md Nasir Wahid

AI-native Engineer & Consultant

Founder of GeekFolks and a full-stack developer with 5+ years of experience across PHP (Laravel, Yii2), Node.js, and Next.js — building scalable, cloud-native systems with a growing focus on AI-driven products.

View full profile →
Diagram of the RAG retrieval loop: six stages split into an offline indexing pipeline (crawl, chunk, embed, store) and an online query pipeline (retrieve, generate), with the loop closing back to re-crawl when content changes
AI & Automation

The Retrieval Loop

What RAG actually does, stage by stage, traced through ChatBotAi — a real Laravel chatbot that crawls a website, indexes it, and answers questions from the retrieved paragraphs alone.

Md Nasir Wahid6 min read
SSl
Software delivery and engineering practice

How to Auto-Renew SSL Certificates on cPanel with acme.sh

Let's Encrypt certificates expire every 90 days. Here's how to automate issuing and deploying them into cPanel with acme.sh and a weekly cron job — no root access required.

Md Nasir Wahid2 min read