如何在 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,重定向请求也可能丢失。
支付确认流程应该包括:
- 接收支付平台 webhook。
- 验证 webhook 签名。
- 根据支付平台的事件 ID 去重。
- 校验支付引用、金额、币种和支付状态。
- 在数据库事务中把结账标记为已支付并创建订单。
- 再次收到相同事件时返回成功,但不重复发货或扣减库存。
下面是示意代码。签名验证方式必须以实际支付平台的 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 则负责保证最终结果。
备注:内容仅供参考。
