PAYATHON 2026

Laravel 如何接收 payment gateway 回调的支付状态

支付老李

结论

这段实现可能同时有两个问题:

  1. Input 没有导入。新版 Laravel 通常通过 Request 读取请求参数。
  2. payment gateway 发起的是站外 POST 请求,不会携带 Laravel 的 CSRF Token。请求可能还没进入 Controller,就被拦截并返回 419。

处理支付回调时,应当为回调路由排除 CSRF 校验,通过注入的 Request 读取参数,验证支付网关签名,再根据网关订单号更新数据库,最后返回网关规定的成功响应。

Controller 中的问题

Controller 导入了 Request,但没有实际使用:

use Illuminate\Http\Request;

代码调用的是:

$status_code = Input::post('status_code');

当前命名空间是 App\Http\Controllers,代码又没有导入 Input。PHP 可能会尝试查找 App\Http\Controllers\Input,随后抛出类不存在错误。

即使显式导入旧版的 Input Facade,也不建议继续使用这种写法。可以把 Request 注入方法:

public function updateTransaction(Request $request)
{
    $statusCode = $request->input('status_code');
}

如果 payment gateway 使用表单格式提交,或者通过正确的 Content-Type: application/json 发送 JSON,input() 通常可以读到对应参数。

定义回调路由

现代 Laravel 可以用类数组形式定义路由:

use App\Http\Controllers\NotifyController;
use Illuminate\Support\Facades\Route;

Route::post('/notify', [NotifyController::class, 'updateTransaction'])
    ->name('payment.notify');

回调不需要额外定义一个路径相同的 GET 路由。浏览器访问 GET /notify,与支付网关请求 POST /notify 是两回事。GET 页面能打开,并不能证明 POST 回调可以正常工作。

传给 payment gateway 的地址应当是完整且可从公网访问的 HTTPS 地址,例如:

$notifyUrl = route('payment.notify');

生产环境的 notify_url 不能使用 localhost、127.0.0.1 或局域网地址。

排除 CSRF 校验

Laravel 默认会校验 Web 路由收到的 POST 请求。payment gateway 无法获得当前应用生成的 CSRF Token,所以需要单独为回调接口排除这项校验。

使用 App\Http\Middleware\VerifyCsrfToken 的 Laravel 项目可以这样配置:

<?php

namespace App\Http\Middleware;

use Illuminate\Foundation\Http\Middleware\VerifyCsrfToken as Middleware;

class VerifyCsrfToken extends Middleware
{
    protected $except = [
        'notify',
    ];
}

一些较新的 Laravel 项目会在 bootstrap/app.php 中配置 CSRF 例外,写法类似:

use Illuminate\Foundation\Configuration\Middleware;

->withMiddleware(function (Middleware $middleware) {
    $middleware->validateCsrfTokens(except: [
        'notify',
    ]);
})

采用哪种写法,要看项目使用的 Laravel 版本和目录结构。只排除支付回调路径,不要关闭整个应用的 CSRF 保护。

接收并记录回调

可以先用下面的代码确认回调是否到达应用:

<?php

namespace App\Http\Controllers;

use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;

class NotifyController extends Controller
{
    public function updateTransaction(Request $request)
    {
        Log::info('Payment notification received', [
            'content_type' => $request->header('Content-Type'),
            'payload' => $request->all(),
            'raw_body' => $request->getContent(),
        ]);

        $statusCode = $request->input('status_code');

        if ($statusCode === null) {
            Log::warning('Payment notification has no status_code');

            return response('Missing status_code', 400);
        }

        Storage::disk('local')->put(
            'payment_status.txt',
            (string) $statusCode
        );

        return response('OK', 200);
    }
}

Storage::disk('local') 对应哪个实际目录,由 config/filesystems.php 决定。不能只根据 Laravel 版本,就断定文件会直接写入 storage/app/payment_status.txt。需要检查 local disk 的 root 配置。

调试日志通常可以在应用日志中查看,例如:

storage/logs/laravel.log

排查 server-to-server callback 时,日志比 print 或 echo 更合适。支付网关通常只检查 HTTP 状态码和响应内容,不会把输出显示在用户浏览器中。

正式环境应更新订单,而不是只写文件

正式环境中,应根据支付网关返回的交易号或商户订单号,把支付状态写入数据库:

<?php

namespace App\Http\Controllers;

use App\Models\Order;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;

class NotifyController extends Controller
{
    public function updateTransaction(Request $request)
    {
        Log::info('Payment callback received', [
            'payload' => $request->all(),
        ]);

        // 参数名必须以支付网关文档为准。
        $orderNumber = $request->input('order_id');
        $statusCode = $request->input('status_code');
        $signature = $request->input('signature');

        if (!$orderNumber || !$statusCode || !$signature) {
            return response('Invalid payload', 400);
        }

        // 必须按照支付网关提供的算法验证签名。
        if (!$this->verifyGatewaySignature($request->all(), $signature)) {
            Log::warning('Invalid payment callback signature', [
                'order_id' => $orderNumber,
            ]);

            return response('Invalid signature', 401);
        }

        DB::transaction(function () use ($orderNumber, $statusCode) {
            $order = Order::where('order_number', $orderNumber)
                ->lockForUpdate()
                ->firstOrFail();

            // 实际成功值必须以网关文档为准。
            if ($statusCode === 'success' && $order->payment_status !== 'paid') {
                $order->payment_status = 'paid';
                $order->paid_at = now();
                $order->save();
            } elseif ($statusCode === 'failure') {
                $order->payment_status = 'failed';
                $order->save();
            }
        });

        // 响应内容必须符合支付网关的回调协议。
        return response('OK', 200);
    }

    private function verifyGatewaySignature(array $payload, string $signature): bool
    {
        // 此处必须实现支付网关官方文档规定的验签算法。
        return false;
    }
}

不要直接使用示例里 verifyGatewaySignature() 的返回值。不同 payment gateway 的字段、签名算法、证书和成功响应格式并不相同,具体实现必须以对应网关的接口文档为准。

仍未收到回调时的排查顺序

可以按下面的顺序检查:

  1. notify_url 是否是完整且可从公网访问的 HTTPS 地址。
  2. payment gateway 实际使用的是 POST、GET 还是其他请求方法。
  3. 回调是否因 CSRF 校验返回 419。
  4. Web 服务器、反向代理或防火墙是否拦截了外部请求。
  5. 参数名是否确实是 status_code,并注意大小写和嵌套结构。
  6. 请求体采用表单、JSON、XML,还是网关自定义格式。
  7. storage 目录是否可写,local disk 的根目录配置是否符合预期。
  8. storage/logs/laravel.log、Web 服务器访问日志和支付网关回调日志中是否记录了错误。
  9. Controller 是否返回了网关要求的 HTTP 2xx 状态码和确认文本。否则,网关可能会反复重试。

支付回调还需要保证幂等性。同一笔支付通知可能发送多次,应用不能因此重复发货、重复增加余额或重复执行其他业务操作。判断支付是否成功时,应以验签后的回调数据或主动查询支付网关得到的结果为准,不能只相信未经验证的 status_code。

备注:内容仅供参考。