PAYATHON 2026

如何在 PHP/Laravel 中设计结账支付流程?

支付老李

结论

用独立的 route、redirect 和 middleware 串联结账步骤没有问题,但 middleware 更适合处理通用访问条件,例如购物车是否为空、结账记录是否属于当前用户。大量流程跳转和状态变更不应放在 middleware 中。

可以采用下面的结构:

  • 为每次结账创建持久化的 Checkout 或 Order 记录。
  • 用明确的状态字段记录当前进度。
  • 每个页面由独立的 controller action 处理。
  • 通过一个流程服务集中判断下一步该去哪里。
  • 在 service 中处理支付创建、支付回调和订单确认。
  • 以支付网关的服务端通知作为最终支付依据,不能只相信浏览器重定向。

这样一来,即使用户刷新页面、返回上一步、重复提交,或付款后未能成功跳转,系统仍可以根据数据库中的状态回到正确步骤。

推荐的流程结构

流程可以设计成:

cart
  ↓
checkout.email
  ↓
checkout.payment
  ↓
checkout.processing
  ↓
checkout.thanks

processing 这一步很有必要。多数第三方支付不会在用户点击按钮后立即返回最终结果,后续可能还要完成 3-D Secure、异步确认或等待 webhook 通知。

建议至少区分以下状态:

enum CheckoutStatus: string
{
    case EMAIL_REQUIRED = 'email_required';
    case PAYMENT_REQUIRED = 'payment_required';
    case PAYMENT_PENDING = 'payment_pending';
    case PAID = 'paid';
    case FAILED = 'failed';
    case EXPIRED = 'expired';
}

如果项目使用的 PHP 版本不支持 enum,也可以改用字符串常量,核心原则相同。

路由设计

路由应该表达资源和操作,不必把所有判断都塞进 middleware:

use App\Http\Controllers\CheckoutController;
use App\Http\Controllers\PaymentWebhookController;
use Illuminate\Support\Facades\Route;

Route::prefix('checkout')->name('checkout.')->group(function () {
    Route::post('/', [CheckoutController::class, 'start'])
        ->name('start');

    Route::get('/{checkout}/email', [CheckoutController::class, 'showEmail'])
        ->name('email.show');

    Route::post('/{checkout}/email', [CheckoutController::class, 'storeEmail'])
        ->name('email.store');

    Route::get('/{checkout}/payment', [CheckoutController::class, 'showPayment'])
        ->name('payment.show');

    Route::post('/{checkout}/payment', [CheckoutController::class, 'createPayment'])
        ->name('payment.create');

    Route::get('/{checkout}/processing', [CheckoutController::class, 'processing'])
        ->name('processing');

    Route::get('/{checkout}/thanks', [CheckoutController::class, 'thanks'])
        ->name('thanks');
});

Route::post('/payments/webhook', PaymentWebhookController::class)
    ->name('payments.webhook');

把 {checkout} 放进 URL,能够明确当前操作的是哪一次结账。如果允许游客结账,不要将可猜测的递增 ID 作为唯一访问凭证。可以使用 UUID 或随机公开令牌,并在服务端校验其归属。

用模型保存结账状态

简化后的 checkouts 表可以包含以下字段:

Schema::create('checkouts', function (Blueprint $table) {
    $table->uuid('id')->primary();
    $table->foreignId('user_id')->nullable()->constrained()->nullOnDelete();
    $table->string('guest_token')->nullable()->unique();
    $table->string('email')->nullable();
    $table->string('status');
    $table->unsignedBigInteger('total_amount');
    $table->char('currency', 3);
    $table->string('payment_reference')->nullable()->unique();
    $table->timestamp('paid_at')->nullable();
    $table->timestamps();
});

金额最好用最小货币单位的整数保存,例如人民币分或美元美分,避免使用浮点数计算:

$totalAmount = 1999; // 19.99,单位取决于业务定义

订单商品、单价、折扣和税费也应该保存为结账快照。支付时不能直接相信前端提交的金额,也不适合每次都从可能已经发生变化的商品表中重新计算历史价格。

模型可以提供状态判断方法:

use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Model;

class Checkout extends Model
{
    use HasUuids;

    protected $fillable = [
        'user_id',
        'guest_token',
        'email',
        'status',
        'total_amount',
        'currency',
        'payment_reference',
        'paid_at',
    ];

    protected function casts(): array
    {
        return [
            'status' => CheckoutStatus::class,
            'paid_at' => 'datetime',
        ];
    }

    public function requiresEmail(): bool
    {
        return $this->status === CheckoutStatus::EMAIL_REQUIRED;
    }

    public function canStartPayment(): bool
    {
        return $this->status === CheckoutStatus::PAYMENT_REQUIRED;
    }

    public function isPaid(): bool
    {
        return $this->status === CheckoutStatus::PAID;
    }
}

集中判断用户该进入哪一步

不要在多个 middleware 和 controller 中重复编写相同的跳转条件。可以创建一个 CheckoutFlow 服务:

namespace App\Services;

use App\Enums\CheckoutStatus;
use App\Models\Checkout;

class CheckoutFlow
{
    public function routeFor(Checkout $checkout): string
    {
        return match ($checkout->status) {
            CheckoutStatus::EMAIL_REQUIRED => route(
                'checkout.email.show',
                $checkout
            ),

            CheckoutStatus::PAYMENT_REQUIRED => route(
                'checkout.payment.show',
                $checkout
            ),

            CheckoutStatus::PAYMENT_PENDING => route(
                'checkout.processing',
                $checkout
            ),

            CheckoutStatus::PAID => route(
                'checkout.thanks',
                $checkout
            ),

            CheckoutStatus::FAILED => route(
                'checkout.payment.show',
                $checkout
            ),

            CheckoutStatus::EXPIRED => route('cart.show'),
        };
    }
}

用户重新打开结账入口时,只需查询当前结账记录,然后执行:

return redirect()->to($flow->routeFor($checkout));

集中处理跳转逻辑更容易维护,也能避免多个 middleware 互相重定向而形成循环。

保持 Controller 轻量

保存 email 时,先验证当前状态,再完成一次明确的状态转换:

use App\Enums\CheckoutStatus;
use App\Http\Requests\StoreCheckoutEmailRequest;
use App\Models\Checkout;
use Illuminate\Http\RedirectResponse;

public function storeEmail(
    StoreCheckoutEmailRequest $request,
    Checkout $checkout
): RedirectResponse {
    $this->authorize('update', $checkout);

    if (! $checkout->requiresEmail()) {
        return redirect()->to(
            app(CheckoutFlow::class)->routeFor($checkout)
        );
    }

    $checkout->update([
        'email' => $request->validated('email'),
        'status' => CheckoutStatus::PAYMENT_REQUIRED,
    ]);

    return to_route('checkout.payment.show', $checkout);
}

请求验证可以单独放到 Form Request 中:

class StoreCheckoutEmailRequest extends FormRequest
{
    public function rules(): array
    {
        return [
            'email' => ['required', 'email:rfc', 'max:254'],
        ];
    }
}

显示支付页时同样要检查状态:

public function showPayment(Checkout $checkout)
{
    $this->authorize('view', $checkout);

    if (! $checkout->canStartPayment()) {
        return redirect()->to(
            app(CheckoutFlow::class)->routeFor($checkout)
        );
    }

    return view('checkout.payment', compact('checkout'));
}

这些判断属于结账业务流程。由 controller 调用流程服务或状态机处理,会比写成通用 middleware 更直观。

支付操作需要幂等

用户可能双击支付按钮、刷新页面,浏览器也可能自动重试请求。createPayment 不能因此创建多个支付请求。

可以为每次结账使用固定的幂等键,具体如何传递取决于支付服务商:

public function createPayment(
    Checkout $checkout,
    PaymentService $payments
): RedirectResponse {
    $this->authorize('update', $checkout);

    if (! $checkout->canStartPayment()) {
        return redirect()->to(
            app(CheckoutFlow::class)->routeFor($checkout)
        );
    }

    $payment = $payments->createForCheckout(
        checkout: $checkout,
        idempotencyKey: 'checkout:'.$checkout->id,
    );

    $checkout->update([
        'payment_reference' => $payment->reference,
        'status' => CheckoutStatus::PAYMENT_PENDING,
    ]);

    return redirect()->away($payment->redirectUrl);
}

PaymentService 应从服务器保存的结账快照中读取金额和币种,不能接受浏览器传来的总价。

如果创建支付和写入本地状态之间有一致性要求,需要结合支付平台的幂等接口、数据库事务及后续对账机制处理。数据库事务无法覆盖第三方支付服务的网络调用,因此只靠 DB::transaction() 不能保证两边绝对同步。

通过 webhook 确认支付结果

支付平台将用户重定向到 /checkout/thanks,只能说明浏览器访问了这个地址,不能证明付款成功。用户可以直接输入 thanks URL,重定向请求也可能丢失。

支付确认流程应该包括:

  1. 接收支付平台 webhook。
  2. 验证 webhook 签名。
  3. 根据支付平台的事件 ID 去重。
  4. 校验支付引用、金额、币种和支付状态。
  5. 在数据库事务中把结账标记为已支付并创建订单。
  6. 再次收到相同事件时返回成功,但不重复发货或扣减库存。

下面是示意代码。签名验证方式必须以实际支付平台的 SDK 和文档为准:

use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;

class PaymentWebhookController
{
    public function __invoke(
        Request $request,
        PaymentService $payments
    ) {
        $event = $payments->verifyWebhook($request);

        if (! $event->isPaymentSucceeded()) {
            return response()->noContent();
        }

        DB::transaction(function () use ($event) {
            $checkout = Checkout::query()
                ->where('payment_reference', $event->paymentReference())
                ->lockForUpdate()
                ->firstOrFail();

            if ($checkout->isPaid()) {
                return;
            }

            if (
                $checkout->total_amount !== $event->amount()
                || $checkout->currency !== $event->currency()
            ) {
                throw new UnexpectedValueException(
                    'Payment amount or currency does not match checkout.'
                );
            }

            $checkout->update([
                'status' => CheckoutStatus::PAID,
                'paid_at' => now(),
            ]);

            Order::createFromCheckout($checkout);
        });

        return response()->noContent();
    }
}

生产环境还应单独保存 webhook 事件 ID,并为它建立唯一索引,防止同一事件被并发处理两次。

middleware 应该负责什么

适合放在 middleware 中的内容包括:

  • 检查 session 中是否存在购物车标识。
  • 验证结账令牌的基本格式。
  • 应用限流。
  • 处理认证要求。
  • 设置通用安全响应头。

以下操作不适合交给 middleware:

  • 写入 email。
  • 改变结账状态。
  • 创建支付。
  • 创建订单。
  • 扣减库存。
  • 根据大量业务状态反复跳转。

这些操作会产生业务副作用,应该放在明确的 service 或 action 中,以便进行事务处理、测试和审计。

权限校验也不能省略。即使使用了 route model binding,仍要通过 Policy 或等价逻辑确认当前用户或游客令牌有权访问该 Checkout。

库存和订单的创建时机

库存处理没有适用于所有商店的统一方案,常见选择有两种:

  • 支付成功后扣减库存:实现简单,但在高并发场景下,可能出现用户付款后库存不足的问题。
  • 进入支付阶段时预留库存:体验更可靠,但必须设置过期时间,并释放未支付的预留库存。

无论选择哪种方式,都应该在数据库事务中锁定相关库存记录,防止并发超卖。不能只根据购物车中是否有该商品来判断库存是否可用。

创建订单也要保证幂等。可以为 orders.checkout_id 添加唯一约束,从数据库层面防止同一次结账生成多个订单。

其他注意事项

  • 不要让 GET 请求修改状态或触发扣款。会产生副作用的操作应使用 POST 等合适的方法。
  • 所有表单都应启用 CSRF 防护。支付平台 webhook 通常需要单独排除 CSRF,但必须验证平台签名。
  • thanks 页面应从服务端读取订单状态,不能采用 query string 中的金额或“支付成功”标记。
  • 支付仍在确认时,应显示 processing 页面,并通过轮询或前端事件更新结果。
  • 为结账记录设置过期时间,避免旧购物车一直处于可支付状态。
  • 记录重要的状态变化、支付引用和 webhook 处理结果,方便退款、对账及问题排查。
  • 支付凭据、银行卡信息等敏感数据应交给合规的支付服务商处理,不要直接存入应用数据库。

现有的 route 和 redirect 思路可以保留,但需要把 checkout 视为一个具有持久状态的业务流程。route 提供入口,controller 负责协调,service 执行业务操作,middleware 处理通用访问条件,数据库状态和支付 webhook 则负责保证最终结果。

备注:内容仅供参考。