laravel-paystation maintained by xenon
Laravel Paystation
Laravel integration for the Paystation payment gateway (Bangladesh).
Companion to the framework-agnostic xenon/paystation SDK. Same endpoints and same field contract, but built for Laravel: config file, facade, auto-discovery, a real RedirectResponse, local validation, and a fully fakeable HTTP layer.
Why this package instead of the plain SDK
xenon/paystation |
xenon/laravel-paystation |
|
|---|---|---|
| Redirect to checkout | echo "<script>…</script>"; exit; |
returns RedirectResponse |
| Credentials | array passed at every call site | config/paystation.php + env |
| Sandbox / live | not switchable | environment config key |
| Bad payload | generic gateway rejection | per-field errors before any request |
| Testing | constructs its own Guzzle client | Http::fake() |
| Guzzle version | pinned `^6.3 | ^7.2` |
| Missing transaction | ambiguous | found() / successful() |
The exit in the SDK is the important one: it ends the PHP process mid-request, so the session is never written, after middleware never runs, and nothing downstream of the call can observe the payment.
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
Then set your credentials:
PAYSTATION_ENV=sandbox
PAYSTATION_MERCHANT_ID=your-merchant-id
PAYSTATION_PASSWORD=your-password
PAYSTATION_CALLBACK_URL=https://yourdomain.com/paystation/callback
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,
'reference' => (string) $order->id,
'cust_name' => $order->customer_name,
'cust_phone' => $order->customer_phone,
'cust_email' => $order->customer_email,
'cust_address' => $order->customer_address,
]);
}
}
currency and callback_url come from config, so you only pass them to override. pay() returns a redirect straight to the hosted checkout page.
When you need the URL instead of a redirect
Useful for APIs, or when you want to persist the invoice number before handing the customer over:
$session = Paystation::createPayment($payload);
$order->update(['invoice_number' => $session->invoiceNumber]);
return response()->json(['checkout_url' => $session->paymentUrl]);
If you omit invoice_number, one is generated for you and returned on the session.
Verifying a payment
Paystation reports an unknown transaction with HTTP 200 and status: failed, so a missing transaction is a normal result here rather than an exception:
$result = Paystation::verify($invoiceNumber, $transactionId);
if (! $result->found()) {
return back()->withErrors($result->message()); // gateway has no such transaction
}
if (! $result->successful()) {
return back()->withErrors('Payment did not succeed: '.$result->transactionStatus());
}
$order->markPaid(
transactionId: $result->transactionId(),
method: $result->paymentMethod(),
amount: $result->amount(),
);
found() means the gateway located the transaction. successful() additionally means the payment itself went through — a located transaction can still carry a failed or pending trx_status, so check the one you actually mean.
Also available: statusCode(), status(), message(), invoiceNumber(), payerMobile(), reference(), and toArray() for the raw payload.
Validation
The payload is validated locally before anything is sent. A bad payload throws InvalidPaymentParameters carrying per-field errors, ready to hand back to a form:
use Xenon\LaravelPaystation\Exceptions\InvalidPaymentParameters;
try {
return Paystation::pay($payload);
} catch (InvalidPaymentParameters $e) {
return back()->withErrors($e->errors());
}
Required: invoice_number, currency, payment_amount, reference, cust_name, cust_phone, cust_email, cust_address, callback_url. Optional passthrough: checkout_items, opt_a … opt_d.
For a fluent build-up, use the payload object:
use Xenon\LaravelPaystation\Data\PaymentPayload;
$payload = PaymentPayload::make()
->set('payment_amount', $order->total)
->set('reference', (string) $order->id)
->set('checkout_items', $order->summary())
->withGeneratedInvoiceNumber('shop-');
return Paystation::pay($payload);
Exceptions
All of them extend PaystationException, so you can catch that one class for anything gateway related.
| Exception | Raised when |
|---|---|
ConfigurationException |
credentials missing, or an environment with no configured endpoint |
InvalidPaymentParameters |
payload rejected locally; carries errors() |
TokenRequestException |
credentials could not be exchanged for a token |
PaymentCreationException |
gateway declined to issue a payment URL; carries context() |
PaystationException |
transport failure or non-JSON / error HTTP response |
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);
Token caching
Each call needs a token, which normally costs an extra grant-token round trip. Caching is off by default because Paystation does not document how long a token stays valid — that matches the plain SDK behaviour. Once you have confirmed the lifetime, turn it on:
PAYSTATION_CACHE_TOKEN=true
PAYSTATION_TOKEN_TTL=1800
Cache keys are scoped per merchant and environment, so switching either never reuses the wrong token. Call Paystation::tokens()->forget() to drop a cached token.
Testing
No network access needed — the package uses the Laravel HTTP client, so fake it:
use Illuminate\Support\Facades\Http;
Http::fake([
'*/grant-token' => Http::response(['token' => 'test-token']),
'*/create-payment' => Http::response([
'status_code' => 200,
'status' => 'success',
'payment_url' => 'https://pay.paystation.com.bd/checkout/test',
]),
]);
$response = Paystation::pay($payload);
$this->assertSame('https://pay.paystation.com.bd/checkout/test', $response->getTargetUrl());
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://api.paystation.com.bd |
endpoints.live |
PAYSTATION_LIVE_URL |
https://api.paystation.com.bd |
defaults.currency |
PAYSTATION_CURRENCY |
BDT |
defaults.callback_url |
PAYSTATION_CALLBACK_URL |
— |
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 |
token.cache |
PAYSTATION_CACHE_TOKEN |
false |
token.store |
PAYSTATION_CACHE_STORE |
default store |
token.ttl |
PAYSTATION_TOKEN_TTL |
1800 |
logging.enabled |
PAYSTATION_LOGGING |
false |
logging.channel |
PAYSTATION_LOG_CHANNEL |
default channel |
Paystation currently serves sandbox and live traffic from the same host and separates them by credentials, which is why both endpoints default to the same URL. Override them if Paystation tells you otherwise.
Logging records the endpoint and the gateway status only — credentials, tokens and customer details are never written.
License
MIT. See LICENSE.