xenon / laravel-paystation
Laravel integration for the Paystation (Bangladesh) payment gateway - config, facade, redirect handling and payment verification.
Requires
- php: ^8.2
- ext-json: *
- guzzlehttp/guzzle: ^7.5|^8.0
- illuminate/events: ^10.0|^11.0|^12.0|^13.0
- illuminate/http: ^10.0|^11.0|^12.0|^13.0
- illuminate/routing: ^10.0|^11.0|^12.0|^13.0
- illuminate/support: ^10.0|^11.0|^12.0|^13.0
Requires (Dev)
- orchestra/testbench: ^8.0|^9.0|^10.0|^11.0
- phpunit/phpunit: ^10.1|^11.0|^12.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-09-21 16:11:08 UTC
README
Laravel integration for the Paystation payment gateway (Bangladesh), built against the official API documentation.
Config file, facade, auto-discovery, a real RedirectResponse, local validation, a fakeable HTTP layer, and a ready-made Merchant IPN endpoint.
Requirements
- PHP 8.2+
- Laravel 10, 11, 12 or 13
Installation
composer require xenon/laravel-paystation
The service provider and the Paystation facade are auto-discovered. Publishing the config is optional (defaults are merged in), but recommended:
php artisan vendor:publish --tag=paystation-config
PAYSTATION_ENV=sandbox PAYSTATION_MERCHANT_ID=104-1653730183 PAYSTATION_PASSWORD=gamecoderstorepass PAYSTATION_CALLBACK_URL=https://yourdomain.com/paystation/callback
Those are the sandbox credentials published in the documentation, so you can send a test payment before you have your own.
Environments
Sandbox and live are genuinely separate hosts with separate credentials:
| Environment | Base URL |
|---|---|
sandbox |
https://sandbox.paystation.com.bd |
live |
https://api.paystation.com.bd |
Taking a payment
use Xenon\LaravelPaystation\Facades\Paystation; class CheckoutController { public function pay(Order $order) { return Paystation::pay([ 'invoice_number' => $order->invoice_number, 'payment_amount' => $order->total, 'cust_name' => $order->customer_name, 'cust_phone' => $order->customer_phone, 'cust_email' => $order->customer_email, ]); } }
pay() posts to /initiate-payment and returns a redirect to the hosted checkout page. Your merchant id and password are added from config, and currency and callback_url come from defaults.
Only these are required by the gateway: invoice_number, payment_amount, cust_name, cust_phone, cust_email, callback_url. currency, reference and cust_address are optional — easy to get wrong, since the older token-based flow demanded them.
Optional passthrough: currency, pay_with_charge, reference, cust_address, checkout_items, opt_a, opt_b, opt_c, emi.
The URL instead of a redirect
$session = Paystation::initiate($payload); $order->update(['invoice_number' => $session->invoiceNumber]); return response()->json(['checkout_url' => $session->paymentUrl]);
$session also carries paymentAmount, message() and the raw response. If you omit invoice_number, one is generated and returned.
EMI and who pays the charge
use Xenon\LaravelPaystation\Data\PaymentPayload; $payload = PaymentPayload::make($fields) ->customerBearsCharge() // pay_with_charge = 1; false => the merchant bears it ->withEmi() // emi = 1 ->set('checkout_items', $order->summary()); return Paystation::pay($payload);
Duplicate invoices
The gateway refuses a reused invoice number with status_code 1008. Retrying with the same number can never succeed, so handle it on its own rather than generating a fresh invoice — otherwise you risk charging twice for one order:
use Xenon\LaravelPaystation\Exceptions\PaymentCreationException; try { return Paystation::pay($payload); } catch (PaymentCreationException $e) { if ($e->isDuplicateInvoice()) { return $this->resumeExistingPayment($order); // look it up, do not re-create } throw $e; }
Checking a transaction
Two endpoints, depending on what you hold:
// v1 -- by invoice number, the reference you always have $result = Paystation::transactionStatus($order->invoice_number); // v2 -- by the gateway's own transaction id, once a payment has been attempted $result = Paystation::transactionStatusV2($trxId);
A transaction the gateway cannot find is a normal result, not an exception. Three states, and conflating any two of them either loses money or ships goods for free:
if (! $result->found()) { return back()->withErrors($result->describeStatusCode()); } if ($result->isPending()) { return back()->with('status', 'Payment still in progress, check again shortly.'); } if (! $result->isPaid()) { return back()->withErrors('Payment did not succeed: '.$result->transactionStatus()); } $order->markPaid( transactionId: $result->transactionId(), method: $result->paymentMethod(), amount: $result->amount(), );
| Method | Meaning |
|---|---|
found() |
the gateway knows this transaction |
isPaid() |
and it succeeded |
isPending() |
and the customer is mid-payment — not a failure |
hasFailed() |
and it failed at card or wallet authentication |
isRefunded() |
and it was refunded |
Also: statusCode(), describeStatusCode(), message(), invoiceNumber(), transactionId(), amount(), matchesAmount(), paymentMethod(), payerMobile(), reference(), orderDateTime(), apiVersion(), toArray(). v1 adds checkoutItems(); v2 adds transactionAmount(), requestedAmount() and transactionDate().
Never compare trx_status yourself. The documentation lists the values lowercase (success, processing, failed, refund) but shows "Success" and "Failed" in its response examples. Every comparison here ignores case; use the helpers or TransactionStatus::is().
Merchant IPN
Paystation posts a server-to-server notification after every successful transaction. Switch the endpoint on:
PAYSTATION_IPN_ENABLED=true PAYSTATION_IPN_PATH=paystation/ipn
That registers POST /paystation/ipn. The gateway has no request parameter for the IPN url — it is configured per merchant, so send Paystation the full HTTPS url separately.
Then handle it:
use Xenon\LaravelPaystation\Ipn\Events\IpnReceived; class FulfilOrder { public function handle(IpnReceived $event): void { $ipn = $event->payload; $order = Order::where('invoice_number', $ipn->invoiceNumber())->first(); if (! $order || ! $ipn->matchesAmount($order->total)) { return; } // idempotency is yours: a notification can arrive more than once if ($order->isPaid()) { return; } $order->markPaid($ipn->trxId(), $ipn->paymentMethod(), $ipn->orderDateTimeString()); } }
The event is dispatched synchronously, before the endpoint answers, so a listener that throws becomes a 500 and the gateway delivers again. Acknowledging a notification nothing recorded would lose the payment silently. Do slow work — emails, third-party calls — in a queued listener so the acknowledgement is not held up.
$event->payload exposes invoiceNumber(), trxStatus(), trxId(), amount(), paymentMethod(), reference(), orderDateTime(), isSuccess(), matchesAmount(), loggable(), get() and toArray().
Verifying notifications
The notification carries no signature and no authentication (Auth: None in the documentation), so anyone who learns the url can post to it. Two optional layers:
# cross-check every notification against the transaction status endpoint PAYSTATION_IPN_CONFIRM=true # and/or refuse anything from another address (plain ips and ipv4 CIDR) PAYSTATION_IPN_TRUSTED_IPS=203.0.113.7,198.51.100.0/24
PAYSTATION_IPN_CONFIRM is the only check that does not rely on trusting the request: the lookup goes out over your own credentials, so a forged body can only pass if the transaction genuinely exists, succeeded and carries the same amount. It costs one round trip. When enabled, $event->confirmation holds the verification payload and $event->wasConfirmed() is true.
Ask Paystation for their sending addresses before using the allow-list — a stale list rejects real payments, and because the gateway retries, the mistake is not immediately obvious.
Listen for Ipn\Events\IpnRejected to see what was refused and why. Because the endpoint is unauthenticated, a rejection is either someone probing the url or a real payment that could not be verified; the second is worth an alert.
What the endpoint answers
The status code is the whole protocol: Paystation stops on 2xx and retries on 4xx, 5xx and timeouts.
| Situation | Status | Why |
|---|---|---|
| accepted and handled | 200 | done |
| unusable body, or not a success | 200 | a resend cannot fix it, so stop the retries |
| untrusted source address | 403 | refuse without granting a retry schedule |
| gateway did not confirm | 503 | might pass later, so ask again |
| a listener threw | 500 | we failed, not the gateway |
Returning 200 on a permanently unusable body is deliberate — a 4xx would buy you the same broken payload for the rest of the retry window.
Multiple merchants and environments
The manager is immutable; overrides return a new instance and never mutate the shared singleton:
Paystation::environment('live')->pay($payload); Paystation::withConfig([ 'merchant_id' => config('services.paystation.second_merchant'), 'password' => config('services.paystation.second_password'), ])->pay($payload);
Testing
No network needed — the package uses the Laravel HTTP client:
Http::fake([ '*/initiate-payment' => Http::response([ 'status_code' => '200', 'status' => 'success', 'payment_url' => 'https://sandbox.paystation.com.bd/checkout/test', ]), ]); $this->assertSame( 'https://sandbox.paystation.com.bd/checkout/test', Paystation::pay($payload)->getTargetUrl() );
One trap worth knowing: Http::fake() merges stubs rather than replacing them, so re-faking the same URL pattern inside a loop leaves the first response answering every later call. Use one case per test.
Run this package's own suite with:
composer install vendor/bin/phpunit
Configuration reference
| Key | Env | Default |
|---|---|---|
environment |
PAYSTATION_ENV |
sandbox |
merchant_id |
PAYSTATION_MERCHANT_ID |
— |
password |
PAYSTATION_PASSWORD |
— |
endpoints.sandbox |
PAYSTATION_SANDBOX_URL |
https://sandbox.paystation.com.bd |
endpoints.live |
PAYSTATION_LIVE_URL |
https://api.paystation.com.bd |
defaults.currency |
PAYSTATION_CURRENCY |
BDT |
defaults.callback_url |
PAYSTATION_CALLBACK_URL |
— |
defaults.pay_with_charge |
PAYSTATION_PAY_WITH_CHARGE |
— (omitted) |
http.timeout |
PAYSTATION_TIMEOUT |
10 |
http.connect_timeout |
PAYSTATION_CONNECT_TIMEOUT |
5 |
http.retry.times |
PAYSTATION_RETRY_TIMES |
1 |
http.retry.sleep |
PAYSTATION_RETRY_SLEEP |
200 |
ipn.enabled |
PAYSTATION_IPN_ENABLED |
false |
ipn.path |
PAYSTATION_IPN_PATH |
paystation/ipn |
ipn.confirm |
PAYSTATION_IPN_CONFIRM |
false |
ipn.trusted_ips |
PAYSTATION_IPN_TRUSTED_IPS |
empty (check skipped) |
logging.enabled |
PAYSTATION_LOGGING |
false |
logging.channel |
PAYSTATION_LOG_CHANNEL |
default channel |
Logging records the endpoint and the gateway status only — the merchant id, password and customer details are never written.
Relationship to xenon/paystation
xenon/paystation is the framework-agnostic SDK. This package is not a wrapper around it: it speaks the documented API directly (/initiate-payment, /transaction-status, /v2/transaction-status) rather than the older token-based grant-token + create-payment flow, and it returns a RedirectResponse instead of writing a script tag and calling exit.
License
MIT. See LICENSE.