Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Omnipay Unionpay Laravel Package

lokielse/omnipay-unionpay

Omnipay driver for China UnionPay (银联). Adds UnionPay payment gateway support to any Omnipay-based PHP application, including Laravel, through a consistent API for payments and related operations. Install via Composer and configure your UnionPay merchant credentials.

View on GitHub
Deep Wiki
Context7

Getting Started

1. Install (and expect a fight)

composer require lokielse/omnipay-unionpay

This driver is built on Omnipay 2.x (omnipay/common:^2.0), not Omnipay 3. That is the single most important fact about it. On a modern Laravel app (10/11, PHP 8.2+) Composer will likely refuse; you will end up pinning omnipay/common:2.6.*, running with --ignore-platform-reqs, or forking. Budget for that before you plan a sprint around it.

2. Where to look first

  • src/Gateway.php — the definitive list of supported operations (purchase, completePurchase, refund, query, and their setX() shortcuts). Read this before the README; it's more accurate.
  • src/Message/*Request.php → getData() — shows exactly which UnionPay fields are sent and how Omnipay params map to them (txnAmt, orderId, txnTime, reqReserved). This is where you go when UnionPay rejects a request.
  • sdk/ — a vendored copy of UnionPay's official PHP SDK (AcpService, SDKConfig, SecureUtil). The driver is a thin Omnipay skin over this; most real debugging ends up in here.
  • Merchant portal — download the .pfx signing cert + password and the .cer files (encrypt, middle, root). Refunds and encrypted-field operations need those .cer files; purchase alone often does not.

3. Configure once, in config

// config/unionpay.php
return [
    'mer_id'        => env('UNIONPAY_MER_ID'),
    'cert_path'     => env('UNIONPAY_CERT_PATH'),        // ABSOLUTE: storage/app/unionpay/acp_sign.pfx
    'cert_password' => env('UNIONPAY_CERT_PASSWORD'),
    'return_url'    => env('APP_URL').'/unionpay/return',
    'notify_url'    => env('APP_URL').'/unionpay/notify',
    'environment'   => env('UNIONPAY_ENV', 'development'), // 'production' | 'development'
];

4. Your first use case: a "Pay now" button

// app/Providers/AppServiceProvider.php
$this->app->bind(UnionPayGateway::class, function () {
    $gateway = Omnipay::create('UnionPay');      // or: new \Lokielse\Omnipay\UnionPay\Gateway();

    $gateway->setMerId(config('unionpay.mer_id'));
    $gateway->setCertPath(config('unionpay.cert_path'));
    $gateway->setCertPassword(config('unionpay.cert_password'));
    $gateway->setReturnUrl(config('unionpay.return_url'));
    $gateway->setNotifyUrl(config('unionpay.notify_url'));

    return $gateway;
});
public function pay(Order $order, UnionPayGateway $gateway)
{
    $response = $gateway->purchase([
        'orderId'     => $order->sn,               // unique, 8–40 chars, alphanumeric
        'txnTime'     => now('Asia/Shanghai')->format('YmdHis'),
        'amount'      => '0.01',                   // verify unit — see Gotchas
        'currency'    => 'CNY',
        'description' => 'Order '.$order->sn,
    ])->send();

    if ($response->isRedirect()) {
        // UnionPay needs a browser POST, not a 302. Render the form yourself:
        return view('unionpay.redirect', [
            'action' => $response->getRedirectUrl(),
            'data'   => $response->getRedirectData(),
        ]);
    }

    Log::error('unionpay purchase failed', ['msg' => $response->getMessage()]);
    return back()->withErrors('Payment could not be started.');
}
{{-- resources/views/unionpay/redirect.blade.php --}}
<form id="pay" method="post" action="{{ $action }}">
  @foreach ($data as $k => $v)
    <input type="hidden" name="{{ $k }}" value="{{ $v }}">
  @endforeach
</form>
<script>document.getElementById('pay').submit();</script>

If Omnipay::create('UnionPay') can't resolve the short name in your Omnipay 2 minor version, instantiate new \Lokielse\Omnipay\UnionPay\Gateway() directly — it always works and is clearer anyway.


Implementation Patterns

Two routes, two very different jobs

UnionPay gives you two callbacks and beginners mix them up:

Route Method Purpose
/unionpay/return POST Browser lands here after payment. UX only.
/unionpay/notify POST Server-to-server truth. Mark orders paid here.

The front-end return is a POST form submission from UnionPay, not a GET redirect. Registering it as Route::get is the classic first-day bug.

Route::post('/unionpay/return', [UnionPayController::class, 'return']);
Route::post('/unionpay/notify', [UnionPayController::class, 'notify'])
     ->withoutMiddleware([\App\Http\Middleware\VerifyCsrfToken::class]);

routes/api.php also works (no CSRF by default) but adds rate limiting you probably don't want on notify.

Notify handler: verify, then trust nothing

public function notify(Request $request, UnionPayGateway $gateway)
{
    Log::channel('unionpay')->info('notify', $request->all());   // keep raw, always

    $response = $gateway->completePurchase()->send();

    if (! $response->isSuccessful()) {
        return response('fail');                                  // UnionPay will retry
    }

    $data = $request->all();

    $order = Order::where('sn', $data['orderId'])->first();
    abort_unless($order, 404);

    // Signature verified — but still cross-check the facts against your DB.
    abort_unless($data['merId'] === config('unionpay.mer_id'), 403);
    abort_unless((int) $data['txnAmt'] === $order->amount_in_fen, 403);

    if ($order->status === 'pending') {
        DB::transaction(function () use ($order, $data) {
            $order->lockForUpdate();
            $order->update([
                'status'      => 'paid',
                'paid_at'     => now(),
                'gateway_ref' => $data['queryId'] ?? $data['transactionId'] ?? null,
            ]);
        });
    }

    return response('ok');   // exactly "ok", lowercase, HTTP 200
}

Return ok fast and do the rest in a queued job if you have side effects (email, provisioning). UnionPay retries notify several times over ~24 hours with backoff; a slow handler means duplicate deliveries piling up.

Front return: same validation, different output

public function return(Request $request, UnionPayGateway $gateway)
{
    $response = $gateway->completePurchase()->send();
    $order = Order::where('sn', $request->input('orderId'))->firstOrFail();

    return $response->isSuccessful()
        ? redirect()->route('orders.show', $order)->with('status', 'Payment received')
        : redirect()->route('orders.show', $order)->withErrors('Payment not confirmed');
}

Reuse completePurchase() in both handlers, but never let the return handler be the only thing that flips an order to paid — users close tabs.

Idempotency is not optional

Notifies duplicate; returns and notifies race each other. Enforce it in the DB:

// migration
$table->unique(['gateway', 'gateway_ref']);   // plus a status-guarded UPDATE

Use the pending → paid conditional update shown above, not $order->update() unconditionally. If you're on MySQL, a guarded UPDATE ... WHERE status = 'pending' and checking affectedRows is the cheapest lock you'll ever write.

Refunds and reconciliation

$response = $gateway->refund([
    'orderId'   => $order->sn,
    'txnTime'   => now('Asia/Shanghai')->format('YmdHis'),
    'txnAmt'    => 100,                     // fen, for partial refunds
    'origQryId' => $order->gateway_ref,     // the queryId from the original payment
])->send();

respCode === '00' means accepted, not refunded. UnionPay settles asynchronously — either consume the refund notify or schedule $gateway->query([...]) and reconcile. Build a nightly job that re-queries every order stuck in pending or refunding for more than N minutes; it is the only reliable source of truth when a notify is lost.

Wrap it, don't sprinkle it

Because this package may not survive your next Laravel upgrade, put it behind your own interface:

interface PaymentGateway {
    public function start(Order $order): string;   // returns redirect HTML/view
    public function confirm(Request $request): PaymentResult;
}

One adapter class, one binding. When you swap to a maintained UnionPay client (or Omnipay 3 + a fork), only that file changes.


Gotchas and Tips

Amount units will bite you. UnionPay's txnAmt is in fen (integer cents), while Omnipay conventions are decimal yuan. The driver converts internally, but the conversion has been a moving target across versions. Do a 0.01 CNY test purchase and log $request->all() on the notify, then assert txnAmt against what you expect. Getting this wrong is silent — you just charge 100× the price.

Certificate paths must be absolute. The bundled SDK resolves some paths relative to getcwd(), which is public/ under php artisan serve and something else under PHP-FPM. Use storage_path('app/unionpay/...') and verify with file_exists() in a boot-time check. Also: .pfx needs a readable password and the PHP openssl extension; test-env certs frequently ship with password 000000 and merId 777290058110097.

Certs expire. UnionPay merchant certs typically last a year. When they lapse, everything fails with opaque signature errors, and it will happen on a Friday night. Add a scheduled check that parses the cert's validTo and alerts 30 days out.

SDKConfig is global static state. The vendored SDK stores configuration statically. Two gateway instances with different merchants in the same process (multi-tenant, or a queue worker that handles both) will clobber each other. Bind one configured gateway per process, or fork the SDK to accept injected config.

The SDK reads superglobals. AcpService::validate() historically validates $_POST rather than an injected array. Behind a proxy, with Content-Type: application/json, or in tests using $this->postJson(), validation silently fails. Always exercise the notify with a real form-encoded POST ($this->post($uri, $payload) in Laravel tests) and assert on the ok body.

Signature failures look identical to everything else. respCode like 05, or a generic "validate failed", usually means: wrong cert, expired cert, stale middle.cer/root.cer, or params re-encoded between verification and use. Don't trim(), urlencode(), or array_filter() the callback payload before verification.

Query responses use origRespCode, not respCode. respCode === '00' on a query call only means "I successfully asked". The original transaction result is in origRespCode. Reading the wrong field is a common reconciliation bug.

Disable or relocate SDK logging in production. The bundled LogUtil writes full request parameters to files (often under sdk/logs/ or /tmp). On shared hosting that's a data-protection problem and a disk-fill problem. Point it at a dedicated, rotated channel.

Don't call $response->redirect() in Laravel. In Omnipay 2 it typically emits headers/HTML and calls exit — skipping middleware, session writes, and terminate(), and making the flow untestable. Render the auto-submit form yourself from getRedirectUrl() + getRedirectData() (shown above).

Test environment is mainland-China-flavoured. environment = 'development' points at gateway.test.95516.com, which can be slow or unreachable from some regions/CI runners. Have a feature flag that lets you stub the gateway in tests rather than depending on the sandbox.

Extension point when you need more fields. The driver only maps the common parameters. For payTimeout, frontFailUrl, reqReserved (great for stuffing your internal reference through the round-trip), or riskRateInfo, subclass the relevant request and override getData():

class PurchaseRequest extends \Lokielse\Omnipay\UnionPay\Message\PurchaseRequest
{
    public function getData()
    {
        return parent::getData() + ['reqReserved' => $this->getReqReserved()];
    }
}

Then override Gateway::createPurchaseRequest() (or purchase()) in a subclass and bind that.

On the stars and activity. 112 stars and no recent releases is a fair signal: this is a working but effectively unmaintained bridge. Treat the sdk/ folder as vendored code you own — pin the exact version in composer.json, commit a lock file, and be prepared to patch the SDK for PHP 8.x deprecations yourself. The adapter-interface pattern above is what makes that survivable.

Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
alphabalex/messaging-made-easy
rawphp/warp
shootkiran/merobug
narakode/fineauth
numencode/wn-syncops-plugin
ibnnajjaar/mib-global-pay
ably/pubsub-server
mstfkhazaal/defender
mfonte/base62x
karnoweb/metapilot
tobya/bcsapiwrapper
swalker2/cpanel
vish4395/laravel-translation-manager
morningtrain/economic
teksite/systeminfo-laravel
atomcms/voting
run6/weather
siokas/laravelembeddirectives
adamlien/pkuuids
gilsonsouza/lara-doc-generator