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 Wechatpay Laravel Package

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.

View on GitHub
Deep Wiki
Context7

Getting Started

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.

Implementation Patterns

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.

Gotchas and Tips

  • This is WeChat Pay API v2 (XML + MD5/HMAC-SHA256). WeChat is deprecating v2 in favour of v3 (JSON + RSA certificates). The package has had no release since 2019, so plan for a migration path; isolate it behind your service class now.
  • Amounts are integers in fen. 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.
  • "签名错误" (signature error) is the default failure. Usual causes: the API key is wrong or has whitespace; empty parameters were included in the signature string; 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.
  • On notify, read the raw body. $request->all() will be empty because the content type is text/xml. Use $request->getContent().
  • The notify URL must be public HTTPS. WeChat cannot reach localhost; there is no retry to an unreachable host beyond its own schedule. Also exclude the route from CSRF and from any auth middleware.
  • Check 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.
  • Validate total_fee, mch_id, and app_id in the callback. Never trust the callback amount alone — compare against your stored order.
  • Duplicate notifications are normal. Make the handler idempotent and return SUCCESS even for already-processed orders; otherwise WeChat retries for hours.
  • Certificate paths must be absolute and readable by the PHP-FPM user. Relative paths resolve against the working directory and fail silently-ish with confusing errors. Do not commit the .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.
  • Dependency conflicts are likely on modern Laravel. The 2019 constraints can clash with current 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.
  • PHP 8 compatibility is not guaranteed. Run the full purchase/notify/refund cycle on your target PHP version early; simpleXML and dynamic property changes can bite.
  • Debugging tip: log the outbound parameter array (redacting 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.
  • Extension point: if a request class lacks a parameter you need, subclass the gateway/request rather than patching vendor/. The gateway's createRequest() mapping is the clean hook.
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.
novasa/laravel-language-center
opencage/geocode
adolfoholzer/parameters
livenetworks/ln-starter
shitoudev/permissionmanager
johannesclimacus/laravel-tui
hocvt/seotools
vitalybaev/sypexgeo
amphibee/themosis-ignition
gito-me/fast-excel
frankyso/laravel-ipaymu
geolize-studio/laravel-chile
boomerangui/chat
cgv/localized-resource
noith/settings
kda/laravel-package
kda/filament-dynamic-navigation
bypersoft/signbox
pairus/product-data
fayne/laravel-filament-cms-core