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.
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 & Founder / CEO

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