amdadulhaq/bd-payment-laravel

Accept payments from Laravel via Bangladeshi gateways (bKash, Nagad, Upay, SSLCommerz) or a manual personal-number driver, behind one driver-based API.

Maintainers

Package info

github.com/amdad121/bd-payment-laravel

pkg:composer/amdadulhaq/bd-payment-laravel

Transparency log

Fund package maintenance!

amdad121

Statistics

Installs: 2

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

v1.0.0 2026-08-23 08:54 UTC

This package is auto-updated.

Last update: 2026-08-23 09:02:47 UTC


README

Latest Version on Packagist GitHub Tests Action Status GitHub Code Style Action Status Total Downloads PHP Version Laravel Version Sponsor

Accept payments from Laravel via Bangladeshi gateways (bKash, Nagad, Upay, SSLCommerz) or a manual personal-number driver, all behind one driver-based API.

Contents

Requirements

  • PHP 8.2, 8.3, 8.4, or 8.5
  • Laravel 11, 12, or 13

Installation

composer require amdadulhaq/bd-payment-laravel

The service provider and Payment facade are auto-discovered. Publish the config file:

php artisan vendor:publish --tag=payment-config

Configuration

Set the default driver and credentials in your .env:

PAYMENT_DRIVER=manual

PAYMENT_MANUAL_NUMBER=01700000000

manual needs no merchant account at all — set a personal/agent number and start collecting payments immediately, reviewing each transaction ID by hand.

Or, for bKash:

PAYMENT_DRIVER=bkash

BKASH_BASE_URL=https://tokenized.sandbox.bka.sh/v1.2.0-beta
BKASH_APP_KEY=your-app-key
BKASH_APP_SECRET=your-app-secret
BKASH_USERNAME=your-username
BKASH_PASSWORD=your-password

Or, for Nagad:

PAYMENT_DRIVER=nagad

NAGAD_MERCHANT_ID=your-merchant-id
NAGAD_MERCHANT_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----..."
NAGAD_PUBLIC_KEY="-----BEGIN PUBLIC KEY-----..."
NAGAD_CALLBACK_URL=https://your-app.test/payments/callback

Or, for Upay:

PAYMENT_DRIVER=upay

UPAY_MERCHANT_ID=your-merchant-id
UPAY_API_KEY=your-api-key

Or, for SSLCommerz:

PAYMENT_DRIVER=sslcommerz

SSLCOMMERZ_STORE_ID=your-store-id
SSLCOMMERZ_STORE_PASSWORD=your-store-password

See config/payment.php for every driver's options. PAYMENT_DRIVER defaults to manual, so nothing breaks in local/testing environments without gateway credentials.

Which driver do I need?

Driver Merchant account needed? Redirects the payer? Server-to-server webhook? Refunds?
manual No — just a personal/agent number No — shows instructions No No
bkash Yes — bKash Tokenized Checkout credentials Yes No (poll/query instead) Yes
nagad Yes — Nagad Merchant API credentials + RSA keys Yes No (poll/query instead) No
upay Yes — Upay merchant credentials Yes No (poll/query instead) No
sslcommerz Yes — SSLCommerz store credentials Yes Yes — real signed IPN Yes

Start with manual if you don't have a merchant account yet — the rest of your app (checkout flow, plan activation, tests) works identically once you switch PAYMENT_DRIVER later, since every driver returns the same PaymentResponse shape.

Accepting a payment

use AmdadulHaq\BdPayment\Facades\Payment;
use AmdadulHaq\BdPayment\DataTransferObjects\PaymentRequest;

$response = Payment::initiate(new PaymentRequest(
    amount: 490.00,
    invoiceNumber: 'INV-1001',
    customerName: $user->name,
    customerEmail: $user->email,
    customerPhone: $user->phone,
    successUrl: route('payments.callback'),
    failUrl: route('payments.callback'),
    cancelUrl: route('payments.callback'),
));

// Redirect-based gateways (bkash/nagad/sslcommerz) hand back a checkout URL.
if ($response->redirectUrl) {
    return redirect($response->redirectUrl);
}

// The manual driver has no redirect — show the payer these instructions instead.
$response->message; // "Send the exact amount via bKash/Nagad Send Money to 01700..."

After the payer returns from the gateway (or, for manual, once they report their transaction ID):

$result = Payment::verify($paymentId, [
    'transaction_id' => $request->input('transaction_id'), // manual driver
]);

if ($result->status->isSuccessful()) {
    // grant access
}

Check a payment's status any time without mutating anything:

Payment::query($paymentId);

Refund a completed payment (not every gateway/driver supports this):

Payment::refund($paymentId, amount: 100.00);

Use a specific driver, or a driver other than the default, for one call — same as Storage::disk():

Payment::driver('sslcommerz')->initiate($request);

Handling webhooks / IPN

Some gateways notify your server directly instead of (or in addition to) redirecting the payer's browser back. Point that URL at a route in your app and hand the request straight to the driver:

use AmdadulHaq\BdPayment\Facades\Payment;

Route::post('/payments/ipn/sslcommerz', function (Request $request) {
    $result = Payment::driver('sslcommerz')->handleWebhook($request->all());

    if ($result->status->isSuccessful()) {
        // grant access
    }

    return response()->noContent();
})->withoutMiddleware([\Illuminate\Foundation\Http\Middleware\VerifyCsrfToken::class]);

Every driver verifies the notification before trusting it — never on the payload alone:

  • SSLCommerz posts a real signed IPN (verify_sign/verify_key). handleWebhook() recomputes the signature from your store password and rejects anything that doesn't match, then still re-confirms the transaction via validationserverAPI as a second check.
  • bKash, Nagad, and Upay have no signed server push — only a browser redirect carrying an ID in the query string, which anyone could forge. handleWebhook() treats that as a hint at most: it extracts the ID and calls the gateway's own status-check endpoint to get the real, authoritative status rather than trusting the query string.
  • manual has no webhook concept — it throws PaymentException.

A forged or tampered payload throws AmdadulHaq\BdPayment\Exceptions\PaymentException rather than returning a fake "success".

API reference

PaymentRequest

Everything a driver might need to start a payment — pass what applies, ignore the rest (e.g. the manual driver ignores every URL field).

Property Type Notes
amount float Required.
invoiceNumber string Required. Your own unique reference — becomes merchantInvoiceNumber (bKash), orderId (Nagad), or tran_id (SSLCommerz).
currency string Defaults to 'BDT'.
customerName, customerEmail, customerPhone, customerAddress ?string Used by SSLCommerz's required customer fields; bKash uses customerPhone as the payer reference.
successUrl, failUrl, cancelUrl ?string Where the payer's browser returns to after paying, on redirect-based drivers.
ipnUrl ?string SSLCommerz-specific: where its server posts the signed IPN.
metadata array<string, mixed> Driver-specific extras — e.g. SSLCommerz reads metadata['product_name'] / metadata['product_category'].

PaymentResponse

What every driver method returns, so calling code never branches on which gateway answered.

Property Type Meaning
status PaymentStatus Normalized status — see below.
invoiceNumber string Echoes back the invoice/order/transaction reference.
gatewayPaymentId ?string The gateway's own payment/session/reference ID — save this, you'll need it for verify()/query()/refund().
transactionId ?string The final settlement transaction ID (bKash trxID, Nagad issuerPaymentRefNo, SSLCommerz bank_tran_id), once available.
amount ?float The confirmed amount, when the gateway reports one.
redirectUrl ?string Where to send the payer next (redirect-based drivers only).
message ?string Human-readable status/instructions (e.g. the manual driver's payment instructions).
raw array<string, mixed> The untouched gateway response — keep this for auditing/debugging, don't build logic on it directly.

PaymentStatus

Case Meaning
Initiated Payment started, payer hasn't completed it yet.
Pending Payer has acted (e.g. submitted a manual transaction ID) but nothing is confirmed yet.
Completed Money has settled — $status->isSuccessful() is true.
Failed Payment did not succeed.
Cancelled Payer backed out.
Refunded A completed payment was refunded.

$status->isFinal() is true for every case except Initiated/Pending.

Adding your own gateway

Register a custom driver:

use AmdadulHaq\BdPayment\Facades\Payment;

Payment::extend('my-gateway', function ($app) {
    return new MyGatewayDriver(/* ... */);
});

Any driver just needs to implement AmdadulHaq\BdPayment\Contracts\PaymentDriver:

interface PaymentDriver
{
    public function initiate(PaymentRequest $request): PaymentResponse;

    public function verify(string $gatewayPaymentId, array $payload = []): PaymentResponse;

    public function query(string $gatewayPaymentId): PaymentResponse;

    public function refund(string $gatewayPaymentId, ?float $amount = null): PaymentResponse;

    public function handleWebhook(array $payload): PaymentResponse;
}

Testing

Use Payment::fake() to swap the real gateway with an in-memory fake and assert on what would have been charged, without dispatching anything or hitting the network:

use AmdadulHaq\BdPayment\Facades\Payment;

$fake = Payment::fake();

// ... code under test that calls Payment::initiate() ...

$fake->assertInitiated('INV-1001');
$fake->assertNothingInitiated();

Troubleshooting

bKash: "Failed to grant an access token." Double-check BKASH_USERNAME/BKASH_PASSWORD (your Tokenized Checkout portal credentials, not your personal bKash PIN) and that BKASH_BASE_URL matches your environment — the sandbox and production base URLs are different hosts, not just different credentials.

Nagad: "Invalid merchant private key." / "Invalid Nagad public key." Both keys must be full PEM strings, including the -----BEGIN ... KEY----- / -----END ... KEY----- lines. When storing a multi-line key in .env, wrap it in quotes and use \n for line breaks, or load it from a file path instead (e.g. NAGAD_MERCHANT_PRIVATE_KEY=file:///path/to/key.pem combined with your own file_get_contents() in a custom service provider binding) rather than pasting it raw into .env.

SSLCommerz: handleWebhook() throws "IPN signature verification failed." Make sure the route receiving the IPN excludes Laravel's CSRF middleware (SSLCommerz's server can't obtain a CSRF token) and that you're passing the entire raw POST payload — $request->all() — to handleWebhook(), not a filtered subset, since the signature covers specific fields named in verify_key.

"Nothing happens" in local development PAYMENT_DRIVER defaults to manual, which never hits the network. If you're expecting a real gateway call, confirm .env actually sets PAYMENT_DRIVER to bkash/nagad/sslcommerz and that you ran php artisan config:clear after changing it (cached config wins over .env).

Running the package's own test suite

composer install
composer test          # Pest
composer analyse        # Larastan
composer lint:check    # Pint

License

MIT. See LICENSE.md.