lokielse/omnipay-wechatpay
Omnipay WeChat Pay gateway for PHP, enabling WeChat Pay payments, refunds, and notifications through the Omnipay payment abstraction. Easy to integrate into Laravel and other PHP applications with a consistent API for online transactions.
Install Omnipay plus the WeChat gateway. Check which Omnipay major the lockfile resolves — this package was last released in 2019 and supports the Omnipay 2/3 era APIs, not WeChat Pay API v3.
composer require lokielse/omnipay-wechatpay
The package has no Laravel service provider, so the first step is a small config block and a container binding. WeChat Pay v2 needs the merchant API key (32 chars) for signing, and the merchant certificate/key files only for refunds and a few sensitive endpoints.
// config/services.php
'wechat' => [
'app_id' => env('WECHAT_APP_ID'),
'mch_id' => env('WECHAT_MCH_ID'),
'api_key' => env('WECHAT_API_KEY'),
'cert_path' => env('WECHAT_CERT_PATH'), // absolute path to apiclient_cert.pem
'key_path' => env('WECHAT_KEY_PATH'), // absolute path to apiclient_key.pem
],
// app/Providers/AppServiceProvider.php
$this->app->singleton(\Omnipay\Common\GatewayInterface::class, function () {
$gateway = \Omnipay\Omnipay::create('WechatPay');
$gateway->setAppId(config('services.wechat.app_id'));
$gateway->setMchId(config('services.wechat.mch_id'));
$gateway->setApiKey(config('services.wechat.api_key'));
return $gateway;
});
If the short name WechatPay does not resolve, pass the fully-qualified gateway class from the package source (vendor/lokielse/omnipay-wechatpay/src/). Look first at the gateway class and PurchaseRequest/RefundRequest to see exactly which parameters and response getters exist in the installed version.
A first end-to-end use case is Native (QR) pay, because it requires no browser-side signing:
$response = $gateway->purchase([
'trade_type' => 'NATIVE',
'body' => 'Order #'.$order->id,
'out_trade_no' => $order->order_no,
'total_fee' => $order->total_cents, // fen, integer
'notify_url' => route('wechat.notify'),
'spbill_create_ip' => $request->ip(),
])->send();
if ($response->isSuccessful()) {
$codeUrl = $response->getCodeUrl() ?? $response->getData()['code_url'];
// render $codeUrl as a QR code
}
Then wire the notify route and confirm you can receive and verify a callback before building anything else.
Wrap the gateway in one application service. Controllers should not touch Omnipay directly. A WechatPayService with charge(), refund(), query(), close(), and handleNotify() keeps the Omnipay 2/3 differences and parameter quirks in one place, and makes it trivial to swap in a fake for tests.
One purchase method per trade type. WeChat requires different parameters per scenario:
'trade_type' => 'NATIVE' // returns code_url for a QR code
'trade_type' => 'JSAPI' // requires 'openid'; returns prepay_id
'trade_type' => 'APP' // returns prepay_id for the mobile SDK
'trade_type' => 'MWEB' // H5; returns mweb_url, add 'scene_info'
For JSAPI you need the user's openid from a WeChat OAuth flow first, then a second signature (paySign) for wx.chooseWXPay. That JSAPI signature is a different signature from the API request signature — if the installed version exposes a helper such as getJsApiParameters(), use it; otherwise compute the timeStamp/nonceStr/package/signType signature yourself with the same API key.
Treat the notify endpoint as the source of truth. The synchronous purchase response only tells you WeChat accepted the order. Fulfilment belongs in the callback:
// routes/web.php — POST, exclude from CSRF
Route::post('/wechat/notify', [WechatNotifyController::class, 'handle'])
->withoutMiddleware(\App\Http\Middleware\VerifyCsrfToken::class);
public function handle(Request $request)
{
$raw = $request->getContent(); // XML body, not form data
$data = $this->service->parseAndVerify($raw); // throws on bad signature
if (($data['return_code'] ?? '') !== 'SUCCESS'
|| ($data['result_code'] ?? '') !== 'SUCCESS') {
return $this->ack(); // still acknowledge; log for investigation
}
DB::transaction(function () use ($data) {
$order = Order::where('order_no', $data['out_trade_no'])->lockForUpdate()->firstOrFail();
// Verify amount, appid, mch_id before trusting anything
abort_unless((int) $data['total_fee'] === $order->total_cents, 422);
abort_unless($data['mch_id'] === config('services.wechat.mch_id'), 422);
if ($order->paid_at) {
return; // idempotent: duplicate notification
}
$order->update([
'paid_at' => now(),
'transaction_id' => $data['transaction_id'],
'paid_payload' => $data, // keep raw for audits
]);
event(new OrderPaid($order));
});
return $this->ack(); // <xml><return_code><![CDATA[SUCCESS]]></return_code>...</xml>
}
Put the fulfilment logic in a transaction with a row lock, store the raw callback, and return the SUCCESS XML quickly. Dispatch slow work (emails, provisioning) to a queue after commit.
Reconcile with query, not hope. Callbacks get lost. A scheduled command should query open orders by out_trade_no and close stale unpaid ones:
$response = $gateway->query(['out_trade_no' => $order->order_no])->send();
if ($response->isSuccessful() && $response->getData()['trade_state'] === 'SUCCESS') {
// mark paid, same idempotent path as the notify handler
}
$gateway->close(['out_trade_no' => $order->order_no])->send();
Refunds are certificate-bound and asynchronous in spirit. Set cert_path/key_path on the gateway before calling refund, pass both total_fee and refund_fee in fen, and use a unique out_refund_no. Persist the returned refund_id; confirm final state via refund query rather than assuming the initial response is terminal. Wrap refunds in a queued job with retries, since a timeout does not mean the refund failed.
Testing. There is no usable WeChat sandbox for this API generation. Build a FakeWechatGateway implementing the same service interface for feature tests, unit-test your signature verification and XML parsing with fixture payloads, and use a tunnel (ngrok/Expose) plus a real 1-fen transaction for the first live run. Keep a .env.testing with dummy credentials so no test accidentally hits production.
total_fee => 100 is ¥1.00. Never pass floats or formatted strings.out_trade_no is max 32 characters and must be unique forever per merchant. UUIDs with hyphens fit; long prefixed IDs often do not. Validate before sending.sign_type differs between signing and verification; XML parsing stripped CDATA (LIBXML_NOCDATA needed); or the notify body was read after Laravel already consumed the input stream. Always sign over non-empty, non-sign parameters sorted by ASCII key.$request->all() will be empty because the content type is text/xml. Use $request->getContent().localhost; there is no retry to an unreachable host beyond its own schedule. Also exclude the route from CSRF and from any auth middleware.return_code and result_code. return_code === SUCCESS only means the request was received; business failures carry result_code === FAIL with err_code/err_code_des. isSuccessful() in Omnipay may not map both.total_fee, mch_id, and app_id in the callback. Never trust the callback amount alone — compare against your stored order..pem files; mount them via secrets.spbill_create_ip should be the end user's IP, not the server's, and must be a valid IPv4. Missing or malformed values cause order creation to fail.time_expire uses Beijing time in YmdHis format. If you set it, compute it in Asia/Shanghai, not the app timezone.attach is a good place to carry your internal order ID, but it is length-limited and returned URL-encoded in callbacks — decode and validate it before use.guzzlehttp/guzzle or omnipay/common. If Composer refuses, prefer upgrading omnipay/common and testing, or isolate this gateway in a separate service; do not force-install with --ignore-platform-reqs in production.api_key and cert paths) and the raw response/notify XML to a dedicated channel. WeChat error messages are terse, and having the exact signed payload is usually what resolves a signature issue.vendor/. The gateway's createRequest() mapping is the clean hook.How can I help you explore Laravel packages today?