sabitahmad/laravel-bkash
Integrate bKash payments into Laravel with full API v1.2.0 support: tokenized and regular checkout, agreements, refunds, queries, token refresh, transaction logging, events, and sandbox/production modes.
This package wraps bKash Tokenized Checkout API v1.2.0 behind a Bkash facade. For day-to-day use, treat the README as the API reference and the published config as the source of truth.
Minimal setup
composer require sabitahmad/laravel-bkash
php artisan vendor:publish --tag="laravel-bkash-config"
php artisan vendor:publish --tag="laravel-bkash-migrations"
php artisan migrate
Add credentials to .env:
BKASH_SANDBOX=true
BKASH_APP_KEY=
BKASH_APP_SECRET=
BKASH_USERNAME=
BKASH_PASSWORD=
BKASH_CALLBACK_URL=/bkash/callback
BKASH_REDIRECT_URL=/payment/redirect
BKASH_LOG_TRANSACTIONS=true
Start by reading these published files in this order:
config/bkash.php — credentials, base URLs, endpoints, retry/timeout, validation limits.SabitAhmad\Bkash\Facades\Bkash — how token refresh and HTTP calls are made.SabitAhmad\Bkash\Responses\* — which fields are normalized and which are raw.examples/BkashController.php mentioned in the README — closest thing to a full reference flow.First useful flow: a regular checkout
Create a payment, store the returned paymentID against your order, then redirect the customer to bKash:
use SabitAhmad\Bkash\Facades\Bkash;
use SabitAhmad\Bkash\Exceptions\BkashException;
try {
$payment = Bkash::createPayment(
payerReference: 'CUST-001',
amount: 100.50,
invoiceNumber: 'INV-001',
);
if (! $payment->isSuccess()) {
throw new RuntimeException($payment->getErrorMessage());
}
// Persist before redirecting; the callback must be able to find this row.
Order::where('invoice_number', 'INV-001')->update([
'bkash_payment_id' => $payment->getPaymentId(),
'payment_state' => 'initiated',
]);
return redirect()->away($payment->getPaymentUrl());
} catch (BkashException $e) {
report($e);
return back()->withErrors('Payment could not be started.');
}
Then handle the callback by executing the payment and verifying the result:
Route::post('/bkash/callback', function (Request $request) {
$payment = Bkash::executePayment($request->input('paymentID'));
if ($payment->isCompleted()) {
// Fulfil against your own order, not against callback-supplied amounts.
event(new PaymentCompleted($payment));
return redirect('/success');
}
return redirect('/failed');
})->withoutMiddleware([\App\Http\Middleware\VerifyCsrfToken::class]);
The first production concern is not the API call itself; it is making your own order state machine authoritative and using bKash responses only as confirmation.
Keep order state in your app, not in bKash responses
Use bKash as an external payment state, but store your own pending, initiated, paid, failed, refunded states. Never fulfil an order solely because a callback arrived. Always execute or query the payment first.
$query = Bkash::queryPayment($order->bkash_payment_id);
if ($query->isCompleted() && $query->getAmount() === (float) $order->amount) {
$order->markPaid($query->getTrxId());
}
For day-to-day code, wrap this in a small application service rather than calling the facade from controllers, jobs, and listeners in different ways:
final class BkashPaymentService
{
public function start(Order $order): string
{
$payment = Bkash::createPayment(
payerReference: $order->customer_reference,
amount: (float) $order->amount,
invoiceNumber: $order->invoice_number,
callbackURL: route('bkash.callback'),
);
// Persist payment ID and return the redirect URL.
$order->forceFill([
'bkash_payment_id' => $payment->getPaymentId(),
'payment_state' => 'initiated',
])->save();
return $payment->getPaymentUrl();
}
public function confirm(Order $order, string $paymentId): bool
{
$result = Bkash::executePayment($paymentId);
if (! $result->isCompleted()) {
$order->markPaymentFailed($result->getErrorMessage());
return false;
}
$order->markPaid($result->getTrxId());
return true;
}
}
Use tokenized checkout for repeat customers
The agreement flow is the reason to choose this package over a thin HTTP wrapper. Create an agreement once, store the agreementId, and pass it into later createPayment calls.
$agreement = Bkash::createAgreement($customer->reference);
if ($agreement->isSuccess()) {
return redirect()->away($agreement->getAgreementUrl());
}
After the customer returns:
$agreement = Bkash::executeAgreement($request->input('paymentID'));
if ($agreement->isSuccess()) {
$customer->update(['bkash_agreement_id' => $agreement->getAgreementId()]);
}
Then future payments:
$payment = Bkash::createPayment(
payerReference: $customer->reference,
amount: 250.00,
invoiceNumber: $invoiceNumber,
callbackURL: route('bkash.callback'),
agreementId: $customer->bkash_agreement_id,
);
Treat agreementId as sensitive reusable payment authorization. Store it encrypted or at least guarded, and provide a cancel path:
Bkash::cancelAgreement($customer->bkash_agreement_id);
Use the helper for prototypes, not as your domain layer
BkashHelper is useful for quick flows and reference generation:
use SabitAhmad\Bkash\Helpers\BkashHelper;
use SabitAhmad\Bkash\Facades\Bkash;
$helper = new BkashHelper(app(Bkash::class));
$invoice = BkashHelper::generateInvoiceNumber('ORDER');
$payerRef = BkashHelper::generatePayerReference('CUSTOMER');
However, the constructor call shown in the README can be awkward because Bkash::class resolves through the facade. Prefer resolving the underlying service from the container or inject what the package actually binds. Check the service provider before relying on this pattern in production.
Use events for side effects, not as the source of truth
The package dispatches PaymentCompleted, PaymentFailed, and AgreementCreated. Use listeners for email, analytics, or fulfilment dispatch, but keep the order transition in the same database transaction as the callback handling where possible.
protected $listen = [
\SabitAhmad\Bkash\Events\PaymentCompleted::class => [
\App\Listeners\SendPaymentReceipt::class,
],
];
If a listener fails, the payment is still real. Do not let a mail failure roll back payment recording; queue listeners or wrap side effects.
Use query and search operations for reconciliation
The package exposes queryPayment, searchTransaction, refundPayment, and refundStatus. Build a scheduled reconciliation job rather than trusting only callbacks:
$result = Bkash::searchTransaction($order->bkash_trx_id);
foreach ($result->getTransactions() as $transaction) {
if (($transaction['transactionStatus'] ?? null) === 'Completed') {
// Compare with local state.
}
}
For refunds, store the refund ID and reconcile separately:
$refund = Bkash::refundPayment($order->bkash_trx_id, 50.00, 'Partial return');
if ($refund->isSuccess()) {
$order->refunds()->create([
'refund_id' => $refund->getRefundId(),
'amount' => $refund->getRefundAmount(),
'status' => $refund->getTransactionStatus(),
]);
}
Standardize response handling
All responses share isSuccess(), getErrorMessage(), getStatusCode(), getRawData(), and toArray(). Build one small adapter so controllers do not branch on response class names:
function bkashError(\SabitAhmad\Bkash\Responses\BaseResponse $response): array
{
return [
'message' => $response->getErrorMessage(),
'code' => $response->getErrorCode(),
'status' => $response->getStatusCode(),
];
}
Log getRawData() on failure, but scrub credentials and tokens before writing to logs.
This is a low-usage package; pin and read the source
With one star and zero dependents, do not assume every documented method matches the installed version. Pin a known version in composer.json, and after upgrading, re-read the changelog and the service class. The README documents a broad API surface, but production behavior lives in the package source and the published config.
The API version is beta
Both base URLs point at v1.2.0-beta Tokenized Checkout endpoints. That is the current bKash product direction, but beta in the URL means endpoint behavior can change. Keep the bKash API version in config and avoid hard-coding URLs in your application.
Sandbox and production are not just credentials
Switching BKASH_SANDBOX changes the base URL. Test the complete redirect and callback flow against sandbox before changing credentials. Callback URLs must be reachable by the browser; a relative BKASH_CALLBACK_URL=/bkash/callback is fine only if your route resolves it correctly and bKash's redirect uses the expected host.
Exclude the callback route from CSRF protection
bKash posts back to your callback URL without your Laravel session token. Register the route outside the web middleware group or exclude it from VerifyCsrfToken. If you use auth middleware on the callback, the session may be missing; identify the order by paymentID instead.
Do not trust callback parameters
The callback can include paymentID, status, and other values. Use paymentID only as a lookup key, then execute or query the payment server-side. Validate that the amount, invoice number, and currency match your order before marking it paid.
Persist before redirecting
A common failure is creating the payment, redirecting, and only persisting the paymentID afterward. The callback may arrive before the redirect response is processed, or the user may close the browser. Write the payment ID and state before returning the redirect.
Make callbacks idempotent
bKash may retry callbacks, users may refresh, and your own retry logic may execute the same payment twice. Use a unique constraint or atomic update on the payment ID and only transition initiated -> paid once. Wrap fulfilment in a transaction or an idempotency key.
DB::transaction(function () use ($paymentId, $result) {
$order = Order::where('bkash_payment_id', $paymentId)->lockForUpdate()->firstOrFail();
if ($order->payment_state === 'paid') {
return;
}
$order->markPaid($result->getTrxId());
});
Use invoice numbers and payer references carefully
invoiceNumber should be unique per payment attempt. Reusing an invoice number can confuse reconciliation and may be rejected by bKash. payerReference is not a secure identifier; treat it as a customer reference, not an authorization token.
Token caching and retries are configurable, but not infinitely resilient
BKASH_TOKEN_CACHE_TTL=3300, BKASH_TIMEOUT, BKASH_RETRY_ATTEMPTS, and BKASH_RETRY_DELAY are exposed. If your queue workers make many bKash calls, keep token cache TTL below the token expiry and avoid retrying non-idempotent operations blindly. Creating a payment twice because of a timeout can produce two payment IDs; reconcile with queryPayment and your own order state.
Validation is configurable — use it deliberately
BKASH_STRICT_VALIDATION, BKASH_MAX_AMOUNT, and BKASH_MIN_AMOUNT affect request validation. Keep strict validation on outside local development. Amount handling should use your application's money representation, not floating-point arithmetic, even though the response methods expose ?float.
Transaction logging may log sensitive data
BKASH_LOG_TRANSACTIONS=true writes transaction records. Review the migration and model before enabling it in production, especially if you store raw request/response payloads. Consider retention and access controls, and never log app secrets or bearer tokens.
The helper's constructor is a signal to check the container binding
new BkashHelper(app(Bkash::class)) looks suspicious because Bkash is documented as a facade. If it fails, inspect the package service provider and resolve the actual client class or use the facade directly. Do not build critical flows around an undocumented constructor shape.
Refunds need their own reconciliation
A successful refund response does not always mean the money has settled. Store getRefundId(), getTransactionStatus(), and getRefundTime(), and poll refundStatus or use search for final reconciliation. Partial refunds should be tracked per refund ID, not as a single order-level flag.
Use the raw response for edge cases
The normalized response classes expose many convenience methods, but bKash may add fields. getRawData() and toArray() are your escape hatch. In logs and support tickets, include the raw response plus your local payment ID, invoice number, and environment.
Watch the events' timing
The package events are dispatched by package code, not by your application. Confirm which events fire on which operation, and whether they fire on failed execution as well as successful completion. Keep your own state transition authoritative and use events only for notifications or downstream work.
How can I help you explore Laravel packages today?