PAYATHON 2026

Laravel 中 Square webhook 无法获取 Auth::user()

支付小周

结论

Auth::user() 返回 null 是正常的。Square webhook 是 Square 服务器发起的独立 HTTP 请求,不会携带付款用户浏览器中的 Laravel Session、Sanctum Cookie 或访问令牌,所以 Laravel 无法从 webhook 请求中还原登录用户。

处理这类请求时,应在创建 Square Checkout、Order 或 Payment 的同时,保存 Square 付款标识与本地用户之间的关联。收到 payment.updated 事件后,从中读取 payment.id、order_id、reference_id 或 customer_id,再查询本地数据库并更新对应用户,不要依赖 Auth::user()。

为什么 webhook 中没有登录用户

用户发起付款和 Square 发送 webhook,是两个彼此独立的请求:

  1. 用户通过浏览器请求 Laravel 应用,请求中可能带有 Session Cookie 或 Sanctum 凭证。
  2. Laravel 创建 Square Checkout、Order 或 Payment。
  3. Square 处理付款。
  4. Square 服务器请求你的 webhook 地址。

第 4 步的请求来自 Square 服务器,不是用户的浏览器。即使用户仍处于登录状态,Square 也拿不到该用户的 Laravel Session Cookie。因此:

Auth::check(); // false
Auth::user();  // null

这和 CSRF 排除、Session 持久化或 Sanctum 配置没有直接关系。CSRF 排除只是允许第三方服务器访问该路由,不会为请求附加用户身份。

日志中的错误:

Attempt to assign property "subscription_status" on null

说明代码正在给一个值为 null 的查询结果赋值,例如:

$user = Auth::user();
$user->subscription_status = 'active';

赋值操作本身没有问题,问题是 $user 并不存在。

推荐的数据关联方式

可以建立一张本地付款表,用来记录本地用户与 Square 付款对象之间的关系:

Schema::create('payments', function (Blueprint $table) {
    $table->id();
    $table->foreignId('user_id')->constrained()->cascadeOnDelete();
    $table->string('square_payment_id')->nullable()->unique();
    $table->string('square_order_id')->nullable()->index();
    $table->string('square_checkout_id')->nullable()->index();
    $table->string('square_customer_id')->nullable()->index();
    $table->string('status')->default('pending');
    $table->timestamps();
});

需要保存哪些字段,取决于项目使用的 Square API 流程。通常可以按以下顺序选择:

  • payment.id:最直接的付款标识。
  • payment.order_id:适合通过 Square Order 关联本地订单。
  • reference_id:如果当前 Square 请求支持,可以写入本地订单号等非敏感业务标识。
  • customer_id:可以辅助关联,但前提是已经保存 Square Customer 与本地用户的映射。

Square 的 customer_id 和 Laravel 的 users.id 来自两个不同的系统,不能默认认为它们相等。

创建付款时保存关联

用户发起创建付款的请求时仍处于登录状态,可以在此时取得用户 ID,并创建本地付款记录:

use Illuminate\Http\Request;
use Illuminate\Support\Str;

public function createCheckout(Request $request)
{
    $user = $request->user();

    $localPayment = Payment::create([
        'user_id' => $user->id,
        'status' => 'pending',
    ]);

    $referenceId = 'payment_'.$localPayment->id;

    // 按当前使用的 Square SDK 构造 Checkout、Order 或 Payment 请求。
    // 如果对应接口支持 reference_id,可将 $referenceId 写入请求。
    $squareResponse = $this->squareService->createCheckout(
        user: $user,
        referenceId: $referenceId,
        idempotencyKey: (string) Str::uuid(),
    );

    $localPayment->update([
        'square_checkout_id' => $squareResponse->getCheckoutId(),
        'square_order_id' => $squareResponse->getOrderId(),
    ]);

    return response()->json([
        'checkout_url' => $squareResponse->getCheckoutUrl(),
    ]);
}

这里的 Square SDK 返回方法仅用于说明代码结构,实际方法名要以项目使用的 SDK 版本和 API 响应为准。重点是在创建 Square 对象时保存本地 user_id,不要等到 webhook 到达后再查找当前登录用户。

如果 Payment ID 只有在付款完成后才会出现,可以先通过 order_id 或业务 reference_id 建立关联,收到第一次 webhook 后,再将 payment.id 写入本地记录。

在 webhook 中查找用户

Square 的 payment.updated 事件通常可以从以下位置取得 Payment 对象:

$payload['data']['object']['payment']

控制器可以这样处理:

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

public function handlePaymentWebhook(Request $request)
{
    $payload = $request->json()->all();

    if (($payload['type'] ?? null) !== 'payment.updated') {
        return response()->json(['received' => true]);
    }

    $squarePayment = data_get($payload, 'data.object.payment');

    if (!is_array($squarePayment)) {
        Log::warning('Square webhook does not contain a payment object', [
            'event_id' => $payload['event_id'] ?? null,
        ]);

        return response()->json(['received' => true]);
    }

    $squarePaymentId = $squarePayment['id'] ?? null;
    $squareOrderId = $squarePayment['order_id'] ?? null;
    $status = $squarePayment['status'] ?? null;

    $localPayment = Payment::query()
        ->when($squarePaymentId, function ($query) use ($squarePaymentId) {
            $query->where('square_payment_id', $squarePaymentId);
        })
        ->when(
            !$squarePaymentId && $squareOrderId,
            function ($query) use ($squareOrderId) {
                $query->where('square_order_id', $squareOrderId);
            }
        )
        ->first();

    // 如果尚未保存 payment ID,可使用 order ID 回退查找。
    if (!$localPayment && $squareOrderId) {
        $localPayment = Payment::where(
            'square_order_id',
            $squareOrderId
        )->first();
    }

    if (!$localPayment) {
        Log::warning('No local payment matches Square webhook', [
            'event_id' => $payload['event_id'] ?? null,
            'square_payment_id' => $squarePaymentId,
            'square_order_id' => $squareOrderId,
        ]);

        // 返回 2xx,避免无法处理的数据被持续重试;
        // 如果该情况属于暂时性故障,也可以按业务策略返回非 2xx。
        return response()->json(['received' => true]);
    }

    DB::transaction(function () use (
        $localPayment,
        $squarePaymentId,
        $status
    ) {
        $localPayment->update([
            'square_payment_id' =>
                $localPayment->square_payment_id ?? $squarePaymentId,
            'status' => $status ?? $localPayment->status,
        ]);

        if ($status === 'COMPLETED') {
            $localPayment->user()->update([
                'subscription_status' => 'active',
            ]);
        }
    });

    return response()->json(['received' => true]);
}

关联模型还需要定义用户关系:

use Illuminate\Database\Eloquent\Relations\BelongsTo;

public function user(): BelongsTo
{
    return $this->belongsTo(User::class);
}

是否启用订阅,应由具体业务规则决定。不能只要收到 payment.updated 就直接开通,因为同一种事件可能包含不同的付款状态。至少要检查 payment.status,并根据业务需要校验金额、货币、门店、订单内容,以及付款是否已退款或取消。

使用 reference_id 关联

如果创建的 Square Order 支持 reference_id,可以在其中写入本地付款编号:

$referenceId = 'local-payment-'.$localPayment->id;

收到 webhook 后,如果 Payment 中包含 order_id,可以查询对应的 Square Order,读取其 reference_id,再查找本地记录:

$localPaymentId = Str::after(
    $squareOrder->getReferenceId(),
    'local-payment-'
);

$localPayment = Payment::find($localPaymentId);

即使编号来自自己创建的 reference_id,也要检查格式,并确认本地付款记录中保存的 Square order_id 与 webhook 一致。不要把未经验证的外部字段直接当作可信的用户 ID。

customer_id 能否直接使用

如果每位 Laravel 用户都有对应的 Square Customer,可以在用户表中保存这项映射:

Schema::table('users', function (Blueprint $table) {
    $table->string('square_customer_id')->nullable()->unique();
});

然后在 webhook 中查询用户:

$squareCustomerId = $squarePayment['customer_id'] ?? null;

$user = User::where(
    'square_customer_id',
    $squareCustomerId
)->first();

只有在付款确实绑定了 Customer 时,这种方式才可靠。访客付款或未关联 Customer 的 Payment 可能不包含 customer_id。一个用户也可能有多笔付款,因此仍建议保留独立的本地付款或订阅记录。

webhook 签名验证

验证签名时必须使用原始请求体,不能先解析 JSON 再重新编码:

$body = $request->getContent();
$signature = $request->header(
    'x-square-hmacsha256-signature'
);

如果手动比较签名,至少要使用常量时间比较:

private function isFromSquare(
    ?string $signature,
    string $body
): bool {
    if (!$signature) {
        return false;
    }

    $hash = hash_hmac(
        'sha256',
        self::NOTIFICATION_URL.$body,
        self::SIGNATURE_KEY,
        true
    );

    return hash_equals(base64_encode($hash), $signature);
}

NOTIFICATION_URL 必须与 Square 后台配置的 webhook URL 完全一致,包括协议、域名、路径和可能存在的尾部斜杠。如果当前 Square SDK 提供官方的 webhook 签名验证辅助方法,应优先使用,并按照所用 SDK 版本的接口调用。

签名密钥也不应硬编码在类中:

private function signatureKey(): string
{
    return config('services.square.webhook_signature_key');
}

环境变量可以这样配置:

SQUARE_WEBHOOK_SIGNATURE_KEY=...
SQUARE_WEBHOOK_NOTIFICATION_URL=https://example.com/payment-plan-webhook

还需要处理重复和乱序事件

webhook 可能重复发送,也不一定严格按照业务顺序到达。生产环境应保存 Square 的 event_id,已经处理过的事件可以直接返回成功:

if (WebhookEvent::where(
    'provider_event_id',
    $payload['event_id']
)->exists()) {
    return response()->json(['received' => true]);
}

最好在同一个数据库事务中更新事件记录、付款状态和订阅状态,并为 provider_event_id 添加唯一索引。这样即使两个相同的 webhook 同时到达,也不会重复开通订阅。

需要避免的做法

不要通过以下方式识别付款用户:

Auth::user();
session('user_id');
config('user_id');

config() 读取的是应用配置,不是当前付款人的运行时身份。把用户 ID 临时写入全局配置、缓存固定键或静态变量,在并发请求中还可能把付款关联到错误的用户。

可靠的关联链路是:

Laravel user
    -> local payment/order
    -> Square checkout/order/payment
    -> Square webhook
    -> local payment/order
    -> Laravel user

只要在创建付款时持久化这条关联,webhook 就不需要依赖 Laravel 的登录状态,也不应该这样做。

备注:内容仅供参考。