Search by

xenon / laravel-paystation

arif98741

Laravel integration for the Paystation (Bangladesh) payment gateway - config, facade, redirect handling and payment verification.

Package info

github.com/arif98741/laravel-paystation

pkg:composer/xenon/laravel-paystation

Statistics

Installs: 3

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

v2.0.0.0 2026-09-21 16:08 UTC

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.