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.
Fund package maintenance!
Requires
- php: ^8.2|^8.3|^8.4|^8.5
- illuminate/http: ^11.0|^12.0|^13.0
- illuminate/support: ^11.0|^12.0|^13.0
Requires (Dev)
- driftingly/rector-laravel: ^2.0
- larastan/larastan: ^3.0
- laravel/pint: ^1.0
- orchestra/testbench: ^9.0|^10.0|^11.0
- pestphp/pest: ^3.0|^4.0
- pestphp/pest-plugin-laravel: ^3.0|^4.0
README
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
- Installation
- Configuration
- Which driver do I need?
- Accepting a payment
- Handling webhooks / IPN
- API reference
- Adding your own gateway
- Testing
- Troubleshooting
- Running the package's own test suite
- License
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 viavalidationserverAPIas 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.