本文目录导读:

我来详细介绍PHP项目中实现收银台(Cashier)和订阅计划的方案。
Laravel Cashier 简介
Laravel Cashier 是处理订阅计费的官方包,支持 Stripe 和 Paddle。
安装 Cashier (Stripe)
composer require laravel/cashier php artisan vendor:publish --tag=cashier-migrations php artisan migrate
数据库迁移设计
// subscriptions 表
Schema::create('subscriptions', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id');
$table->string('name'); // 订阅名称
$table->string('stripe_id')->unique();
$table->string('stripe_status');
$table->string('stripe_price')->nullable();
$table->integer('quantity')->nullable();
$table->timestamp('trial_ends_at')->nullable();
$table->timestamp('ends_at')->nullable();
$table->timestamps();
});
// subscription_items 表
Schema::create('subscription_items', function (Blueprint $table) {
$table->id();
$table->foreignId('subscription_id');
$table->string('stripe_id')->unique();
$table->string('stripe_product');
$table->string('stripe_price');
$table->integer('quantity')->nullable();
$table->timestamps();
});
订阅计划模型
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class Plan extends Model
{
protected $fillable = [
'name',
'slug',
'stripe_price_id',
'description',
'price',
'currency',
'interval',
'interval_count',
'trial_period_days',
'features',
'sort_order',
'is_active',
'is_popular'
];
protected $casts = [
'features' => 'array',
'is_active' => 'boolean',
'is_popular' => 'boolean',
'price' => 'decimal:2'
];
// 价格格式化
public function formattedPrice(): string
{
return $this->currency . ' ' . number_format($this->price / 100, 2);
}
// 周期描述
public function intervalDescription(): string
{
$intervals = [
'day' => '天',
'week' => '周',
'month' => '月',
'year' => '年'
];
$intervalName = $intervals[$this->interval] ?? $this->interval;
if ($this->interval_count > 1) {
return "每{$this->interval_count}{$intervalName}";
}
return "每月";
}
// 获取推荐计划
public function scopePopular($query)
{
return $query->where('is_popular', true);
}
// 获取可用计划
public function scopeActive($query)
{
return $query->where('is_active', true)->orderBy('sort_order');
}
}
订阅服务类
<?php
namespace App\Services;
use App\Models\Plan;
use App\Models\User;
use Stripe\Stripe;
use Stripe\Checkout\Session;
use Stripe\Customer;
use Stripe\Subscription;
class SubscriptionService
{
public function __construct()
{
Stripe::setApiKey(config('cashier.secret'));
}
// 创建结账会话
public function createCheckoutSession(User $user, Plan $plan): Session
{
return Session::create([
'customer' => $this->getOrCreateCustomer($user),
'mode' => 'subscription',
'line_items' => [[
'price' => $plan->stripe_price_id,
'quantity' => 1,
]],
'success_url' => route('subscription.success', [], true),
'cancel_url' => route('subscription.cancel', [], true),
'metadata' => [
'user_id' => $user->id,
'plan_id' => $plan->id
]
]);
}
// 获取或创建 Stripe 客户
private function getOrCreateCustomer(User $user): string
{
if ($user->stripe_id) {
return $user->stripe_id;
}
$customer = Customer::create([
'email' => $user->email,
'name' => $user->name,
'metadata' => [
'user_id' => $user->id
]
]);
$user->stripe_id = $customer->id;
$user->save();
return $customer->id;
}
// 升级/降级订阅
public function swapSubscription(User $user, Plan $newPlan): bool
{
if (!$user->subscribed('default')) {
return false;
}
$subscription = $user->subscription('default');
try {
$subscription->swap($newPlan->stripe_price_id);
return true;
} catch (\Exception $e) {
report($e);
return false;
}
}
// 取消订阅
public function cancelSubscription(User $user): bool
{
if (!$user->subscribed('default')) {
return false;
}
try {
$subscription = $user->subscription('default');
// 立即取消
// $subscription->cancelNow();
// 周期结束后取消
$subscription->cancel();
return true;
} catch (\Exception $e) {
report($e);
return false;
}
}
// 恢复订阅
public function resumeSubscription(User $user): bool
{
if (!$user->subscription('default')->cancelled()) {
return false;
}
try {
$user->subscription('default')->resume();
return true;
} catch (\Exception $e) {
report($e);
return false;
}
}
// 获取订阅状态
public function getSubscriptionStatus(User $user): array
{
if (!$user->subscribed('default')) {
return ['status' => 'none'];
}
$subscription = $user->subscription('default');
return [
'status' => $subscription->stripe_status,
'plan_id' => $subscription->stripe_price,
'ends_at' => $subscription->ends_at,
'trial_ends_at' => $subscription->trial_ends_at,
'can_cancel' => $subscription->active(),
'can_resume' => $subscription->cancelled() && !$subscription->ended(),
'on_grace_period' => $subscription->onGracePeriod()
];
}
}
Webhook 处理
<?php
namespace App\Http\Controllers;
use Laravel\Cashier\Http\Controllers\WebhookController as CashierController;
class StripeWebhookController extends CashierController
{
// 支付成功
protected function handleInvoicePaymentSucceeded($payload)
{
$subscriptionId = $payload['data']['object']['subscription'];
$userId = $payload['data']['object']['metadata']['user_id'] ?? null;
if ($userId) {
$user = User::find($userId);
// 更新用户角色或权限
$user->assignRole('subscriber');
// 记录日志
Log::info('Payment succeeded for user', [
'user_id' => $userId,
'subscription_id' => $subscriptionId
]);
}
return response()->json(['status' => 'success']);
}
// 支付失败
protected function handleInvoicePaymentFailed($payload)
{
$userId = $payload['data']['object']['metadata']['user_id'] ?? null;
if ($userId) {
$user = User::find($userId);
// 发送通知
$user->notify(new PaymentFailed());
Log::warning('Payment failed for user', [
'user_id' => $userId
]);
}
return response()->json(['status' => 'success']);
}
// 订阅取消
protected function handleCustomerSubscriptionDeleted($payload)
{
$userId = $payload['data']['object']['metadata']['user_id'] ?? null;
if ($userId) {
$user = User::find($userId);
// 更新权限
$user->removeRole('subscriber');
Log::info('Subscription cancelled for user', [
'user_id' => $userId
]);
}
return response()->json(['status' => 'success']);
}
}
前端 Vue 组件示例
<template>
<div class="pricing-container">
<div class="text-center mb-8">
<h2 class="text-3xl font-bold">选择你的计划</h2>
<p class="mt-4 text-gray-600">随时可以升级或降级</p>
</div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-8">
<div
v-for="plan in plans"
:key="plan.id"
:class="['plan-card', { 'popular': plan.is_popular }]"
>
<!-- 推荐标签 -->
<div v-if="plan.is_popular" class="popular-badge">
最受欢迎
</div>
<!-- 计划信息 -->
<div class="plan-header">
<h3 class="text-xl font-bold">{{ plan.name }}</h3>
<p class="text-gray-600 mt-2">{{ plan.description }}</p>
</div>
<!-- 价格 -->
<div class="price-section">
<span class="price">${{ plan.price / 100 }}</span>
<span class="interval">/{{ plan.interval }}</span>
</div>
<!-- 功能列表 -->
<ul class="features-list">
<li v-for="feature in plan.features" :key="feature" class="feature-item">
<CheckIcon class="w-5 h-5 text-green-500" />
{{ feature }}
</li>
</ul>
<!-- 操作按钮 -->
<button
@click="handleSubscribe(plan)"
class="subscribe-btn"
>
{{ userSubscribed ? '切换到此计划' : '立即订阅' }}
</button>
</div>
</div>
<!-- 当前订阅信息 -->
<div v-if="currentSubscription" class="current-subscription">
<h3 class="text-lg font-semibold">当前订阅</h3>
<p>计划: {{ currentSubscription.plan_name }}</p>
<p>状态: {{ getStatusText(currentSubscription.status) }}</p>
<p v-if="currentSubscription.ends_at">
将在: {{ currentSubscription.ends_at }} 结束
</p>
<button
v-if="currentSubscription.can_cancel"
@click="handleCancel"
class="cancel-btn"
>
取消订阅
</button>
<button
v-if="currentSubscription.can_resume"
@click="handleResume"
class="resume-btn"
>
恢复订阅
</button>
</div>
</div>
</template>
<script>
import { ref, onMounted } from 'vue'
import { CheckIcon } from '@heroicons/vue/solid'
export default {
components: {
CheckIcon
},
setup() {
const plans = ref([])
const currentSubscription = ref(null)
const userSubscribed = ref(false)
// 获取付款链接
const getCheckoutUrl = async () => {
try {
const response = await axios.post('/api/subscription/checkout', {
plan_id: planId
})
return response.data.url
} catch (error) {
console.error('Failed to get checkout URL:', error)
throw error
}
}
// 订阅处理
const handleSubscribe = async (plan) => {
try {
// 检查是否已订阅
const subscriptionStatus = await checkSubscription()
if (subscriptionStatus.subscribed) {
// 已订阅则进行计划切换
await swapSubscription(plan.id)
alert('计划切换成功!')
} else {
// 未订阅则创建结账会话
const checkoutUrl = await getCheckoutUrl(plan.id)
window.location.href = checkoutUrl
}
} catch (error) {
console.error('Subscription error:', error)
alert('操作失败,请重试')
}
}
// 取消订阅
const handleCancel = async () => {
if (!confirm('确定要取消订阅吗?')) return
try {
await axios.post('/api/subscription/cancel')
alert('订阅已取消')
await fetchStatus()
} catch (error) {
console.error('Cancel error:', error)
}
}
// 恢复订阅
const handleResume = async () => {
try {
await axios.post('/api/subscription/resume')
alert('订阅已恢复')
await fetchStatus()
} catch (error) {
console.error('Resume error:', error)
}
}
// 获取状态文本
const getStatusText = (status) => {
const statusMap = {
'active': '活跃',
'canceled': '已取消',
'past_due': '逾期',
'trialing': '试用中',
'incomplete': '未完成'
}
return statusMap[status] || status
}
// 初始化
onMounted(async () => {
try {
const [plansResponse, subscriptionResponse] = await Promise.all([
axios.get('/api/plans'),
axios.get('/api/subscription/status')
])
plans.value = plansResponse.data.data
currentSubscription.value = subscriptionResponse.data
userSubscribed.value = subscriptionResponse.data.status !== 'none'
} catch (error) {
console.error('Initialization error:', error)
}
})
return {
plans,
currentSubscription,
userSubscribed,
handleSubscribe,
handleCancel,
handleResume,
getStatusText
}
}
}
</script>
<style scoped>
.pricing-container {
@apply max-w-7xl mx-auto py-12 px-4;
}
.plan-card {
@apply relative bg-white rounded-lg shadow-lg p-8 border border-gray-200;
}
.plan-card.popular {
@apply border-blue-500 shadow-xl;
transform: scale(1.05);
}
.popular-badge {
@apply absolute -top-3 left-1/2 transform -translate-x-1/2
bg-blue-500 text-white px-4 py-1 rounded-full text-sm font-semibold;
}
.plan-header {
@apply text-center mb-6;
}
.price-section {
@apply text-center mb-6;
}
.price {
@apply text-4xl font-bold;
}
.interval {
@apply text-gray-500 ml-1;
}
.features-list {
@apply space-y-3 mb-8;
}
.feature-item {
@apply flex items-center text-gray-600;
}
.subscribe-btn {
@apply w-full bg-blue-500 text-white py-3 rounded-lg font-semibold
hover:bg-blue-600 transition duration-200;
}
.current-subscription {
@apply mt-12 p-6 bg-gray-50 rounded-lg;
}
.cancel-btn {
@apply mt-4 bg-red-500 text-white px-6 py-2 rounded-lg
hover:bg-red-600 transition duration-200;
}
.resume-btn {
@apply mt-4 ml-4 bg-green-500 text-white px-6 py-2 rounded-lg
hover:bg-green-600 transition duration-200;
}
</style>
API 路由
// routes/api.php
Route::prefix('subscription')->group(function () {
Route::post('checkout', [SubscriptionController::class, 'createCheckout']);
Route::post('swap', [SubscriptionController::class, 'swapSubscription']);
Route::post('cancel', [SubscriptionController::class, 'cancelSubscription']);
Route::post('resume', [SubscriptionController::class, 'resumeSubscription']);
Route::get('status', [SubscriptionController::class, 'getStatus']);
Route::get('invoices', [SubscriptionController::class, 'getInvoices']);
});
Route::get('plans', [PlanController::class, 'index']);
// Webhook 不需要认证
Route::post('stripe/webhook', [StripeWebhookController::class, 'handleWebhook']);
订阅控制器
<?php
namespace App\Http\Controllers\Api;
use App\Models\Plan;
use App\Models\User;
use App\Services\SubscriptionService;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
class SubscriptionController extends Controller
{
protected $subscriptionService;
public function __construct(SubscriptionService $subscriptionService)
{
$this->subscriptionService = $subscriptionService;
$this->middleware('auth:sanctum');
}
// 创建结账
public function createCheckout(Request $request)
{
$request->validate([
'plan_id' => 'required|exists:plans,id'
]);
$plan = Plan::findOrFail($request->plan_id);
$user = Auth::user();
try {
$session = $this->subscriptionService->createCheckoutSession($user, $plan);
return response()->json([
'url' => $session->url
]);
} catch (\Exception $e) {
return response()->json([
'message' => '创建结账会话失败'
], 500);
}
}
// 获取订阅状态
public function getStatus()
{
$user = Auth::user();
$status = $this->subscriptionService->getSubscriptionStatus($user);
return response()->json($status);
}
// 获取发票
public function getInvoices()
{
$user = Auth::user();
if (!$user->hasStripeId()) {
return response()->json([]);
}
$invoices = $user->invoices()->map(function ($invoice) {
return [
'id' => $invoice->id,
'amount' => $invoice->total(),
'date' => $invoice->date()->toFormattedDateString(),
'status' => $invoice->status,
'pdf' => $invoice->invoice_pdf
];
});
return response()->json($invoices);
}
}
计划控制器
<?php
namespace App\Http\Controllers\Api;
use App\Models\Plan;
use Illuminate\Http\Request;
class PlanController extends Controller
{
public function index()
{
$plans = Plan::active()->get()->map(function ($plan) {
return [
'id' => $plan->id,
'name' => $plan->name,
'slug' => $plan->slug,
'description' => $plan->description,
'price' => $plan->price,
'formatted_price' => $plan->formattedPrice(),
'currency' => $plan->currency,
'interval' => $plan->interval,
'interval_description' => $plan->intervalDescription(),
'features' => $plan->features,
'is_popular' => $plan->is_popular,
'trial_period_days' => $plan->trial_period_days
];
});
return response()->json([
'data' => $plans
]);
}
}
数据库种子数据
<?php
namespace Database\Seeders;
use App\Models\Plan;
use Illuminate\Database\Seeder;
class PlanSeeder extends Seeder
{
public function run()
{
$plans = [
[
'name' => '基础版',
'slug' => 'basic',
'stripe_price_id' => 'price_basic_monthly',
'description' => '适合个人用户',
'price' => 999, // $9.99
'currency' => 'USD',
'interval' => 'month',
'interval_count' => 1,
'trial_period_days' => 7,
'features' => json_encode([
'5个项目',
'基础支持',
'1GB存储空间',
'社区访问'
]),
'sort_order' => 1,
'is_active' => true,
'is_popular' => false
],
[
'name' => '专业版',
'slug' => 'pro',
'stripe_price_id' => 'price_pro_monthly',
'description' => '适合专业开发者',
'price' => 2999, // $29.99
'currency' => 'USD',
'interval' => 'month',
'interval_count' => 1,
'trial_period_days' => 14,
'features' => json_encode([
'不限项目',
'优先支持',
'50GB存储空间',
'API访问',
'团队协作',
'高级分析'
]),
'sort_order' => 2,
'is_active' => true,
'is_popular' => true
],
[
'name' => '企业版',
'slug' => 'enterprise',
'stripe_price_id' => 'price_enterprise_monthly',
'description' => '适合大型团队',
'price' => 9999, // $99.99
'currency' => 'USD',
'interval' => 'month',
'interval_count' => 1,
'trial_period_days' => 30,
'features' => json_encode([
'不限项目',
'24/7专属支持',
'500GB存储空间',
'完整API访问',
'高级团队协作',
'自定义功能',
'SSO集成',
'专属客户经理'
]),
'sort_order' => 3,
'is_active' => true,
'is_popular' => false
]
];
foreach ($plans as $plan) {
Plan::create($plan);
}
}
}
环境配置
# .env 文件配置 STRIPE_KEY=pk_test_your_publishable_key STRIPE_SECRET=sk_test_your_secret_key STRIPE_WEBHOOK_SECRET=whsec_your_webhook_secret CASHIER_CURRENCY=usd CASHIER_CURRENCY_LOCALE=en
这个完整的订阅系统包含:
- 数据库设计 - 灵活的计划和订阅模型
- 订阅管理 - 创建、升级、降级、取消、恢复
- 支付集成 - Stripe 结账、发票管理
- Webhook 处理 - 支付成功/失败、订阅取消
- 前端组件 - 响应式的定价页面
- API 接口 - RESTful 的订阅管理 API
- 安全措施 - 认证、验证、错误处理
可以根据需求扩展功能,
- 多货币支持
- 优惠券/折扣
- 试用期管理
- 订阅提醒
- 用量计费