主题
Laravel Cashier (Paddle)
简介
WARNING
本文档适用于 Cashier Paddle 2.x 与 Paddle Billing 的集成。如果你仍在使用 Paddle Classic,应使用 Cashier Paddle 1.x。
Laravel Cashier Paddle 为 Paddle 的订阅计费服务提供了一个表达性强、链式友好的接口。它处理了几乎所有你并不想手写的订阅计费样板代码。除了基础的订阅管理之外,Cashier 还支持:切换订阅、订阅“数量”、订阅暂停、取消宽限期等。
在深入了解 Cashier Paddle 之前,建议你同时阅读 Paddle 的概念指南和 API 文档。
升级 Cashier
升级到 Cashier 的新版本时,务必仔细阅读升级指南。
安装
首先,使用 Composer 包管理器安装 Paddle 版本的 Cashier:
shell
composer require laravel/cashier-paddle接着,使用 vendor:publish Artisan 命令发布 Cashier 的迁移文件:
shell
php artisan vendor:publish --tag="cashier-migrations"然后,运行应用的数据库迁移。Cashier 的迁移会创建新的 customers 表。此外,还会创建新的 subscriptions 和 subscription_items 表,用于存储客户的所有订阅。最后,还会创建一个新的 transactions 表,用于存储与客户相关的所有 Paddle 交易:
shell
php artisan migrateWARNING
为确保 Cashier 能正确处理所有 Paddle 事件,请记得设置 Cashier 的 webhook 处理。
Paddle Sandbox
在本地和预发布环境开发期间,你应注册一个 Paddle Sandbox 账号。这个账号会为你提供一个沙箱环境,用于测试和开发应用,而不会产生真实支付。你可以使用 Paddle 的测试卡号来模拟各种支付场景。
使用 Paddle Sandbox 环境时,应在应用的 .env 文件中将 PADDLE_SANDBOX 环境变量设置为 true:
ini
PADDLE_SANDBOX=true完成开发后,你可以申请 Paddle vendor 账号。在应用正式投入生产前,Paddle 需要先审核你的应用域名。
配置
Billable 模型
在使用 Cashier 之前,你必须将 Billable trait 添加到用户模型定义中。这个 trait 提供了多种方法,使你可以执行常见的计费任务,例如创建订阅和更新支付方式信息:
php
use Laravel\Paddle\Billable;
class User extends Authenticatable
{
use Billable;
}如果你有不是用户但也需要计费的实体,也可以将该 trait 添加到这些类中:
php
use Illuminate\Database\Eloquent\Model;
use Laravel\Paddle\Billable;
class Team extends Model
{
use Billable;
}API Keys
接下来,你应在应用的 .env 文件中配置 Paddle 密钥。你可以从 Paddle 控制面板获取这些 API keys:
ini
PADDLE_CLIENT_SIDE_TOKEN=your-paddle-client-side-token
PADDLE_API_KEY=your-paddle-api-key
PADDLE_RETAIN_KEY=your-paddle-retain-key
PADDLE_WEBHOOK_SECRET="your-paddle-webhook-secret"
PADDLE_SANDBOX=true当你使用的是 Paddle Sandbox 环境时,PADDLE_SANDBOX 环境变量应设置为 true。如果你要将应用部署到生产环境并使用 Paddle 的正式 vendor 环境,则应将 PADDLE_SANDBOX 设置为 false。
PADDLE_RETAIN_KEY 是可选的,仅当你在 Paddle 中使用 Retain 时才需要设置。
Paddle JS
Paddle 依赖其自己的 JavaScript 库来初始化 Paddle 结账组件。你可以将 @paddleJS Blade 指令放在应用布局关闭 </head> 标签之前,以加载该 JavaScript 库:
blade
<head>
...
@paddleJS
</head>货币配置
你可以指定一个 locale,用于在发票中格式化金额显示。Cashier 内部使用 PHP 的 NumberFormatter 类 来设置货币 locale:
ini
CASHIER_CURRENCY_LOCALE=nl_BEWARNING
如果你要使用 en 之外的 locale,请确保服务器已安装并配置 ext-intl PHP 扩展。
覆盖默认模型
你可以自由扩展 Cashier 内部使用的模型,只需定义自己的模型并继承对应的 Cashier 模型:
php
use Laravel\Paddle\Subscription as CashierSubscription;
class Subscription extends CashierSubscription
{
// ...
}定义模型后,你可以通过 Laravel\Paddle\Cashier 类告诉 Cashier 使用你的自定义模型。通常,你应在应用 App\Providers\AppServiceProvider 类的 boot 方法中完成配置:
php
use App\Models\Cashier\Subscription;
use App\Models\Cashier\Transaction;
/**
* Bootstrap any application services.
*/
public function boot(): void
{
Cashier::useSubscriptionModel(Subscription::class);
Cashier::useTransactionModel(Transaction::class);
}快速开始
销售产品
NOTE
在使用 Paddle Checkout 之前,你应在 Paddle 仪表板中定义固定价格的产品。此外,你还应配置 Paddle 的 webhook 处理。
通过应用提供产品和订阅计费通常会让人望而生畏。但借助 Cashier 和 Paddle Checkout Overlay,你可以轻松构建现代且可靠的支付集成。
若要为非周期性、一次性收费产品向客户收费,我们将使用 Cashier 配合 Paddle Checkout Overlay。在 Overlay 中,客户会填写支付信息并确认购买。支付完成后,客户将被重定向到你在应用中指定的成功 URL:
php
use Illuminate\Http\Request;
Route::get('/buy', function (Request $request) {
$checkout = $request->user()->checkout('pri_deluxe_album')
->returnTo(route('dashboard'));
return view('buy', ['checkout' => $checkout]);
})->name('checkout');如上例所示,我们使用 Cashier 提供的 checkout 方法,为指定的“price identifier”创建一个结账对象,从而向客户展示 Paddle Checkout Overlay。在 Paddle 中,“price”指的是某个具体产品对应的已定义价格。
如有必要,checkout 方法会自动在 Paddle 中创建 customer,并将该 Paddle customer 记录与应用数据库中的对应用户关联。结账完成后,客户会被重定向到一个专门的成功页面,你可以在那里向客户显示提示信息。
在 buy 视图中,我们会包含一个按钮来显示 Checkout Overlay。Cashier Paddle 自带 paddle-button Blade 组件;当然,你也可以手动渲染 overlay checkout:
html
<x-paddle-button :checkout="$checkout" class="px-8 py-4">
Buy Product
</x-paddle-button>向 Paddle Checkout 提供 Meta Data
在销售产品时,通常需要通过你自己的应用中定义的 Cart 和 Order 模型来跟踪已完成订单和已购买产品。当把客户重定向到 Paddle Checkout Overlay 完成购买时,你可能需要传递现有订单标识符,以便在客户返回应用后,将已完成的购买与对应订单关联。
要实现这一点,你可以向 checkout 方法传递一组自定义数据。设想当用户开始结账流程时,应用中会创建一个待完成的 Order。请注意,这里的 Cart 和 Order 模型只是示例,并非 Cashier 提供。你可以根据自身应用需求自由实现这些概念:
php
use App\Models\Cart;
use App\Models\Order;
use Illuminate\Http\Request;
Route::get('/cart/{cart}/checkout', function (Request $request, Cart $cart) {
$order = Order::create([
'cart_id' => $cart->id,
'price_ids' => $cart->price_ids,
'status' => 'incomplete',
]);
$checkout = $request->user()->checkout($order->price_ids)
->customData(['order_id' => $order->id]);
return view('billing', ['checkout' => $checkout]);
})->name('checkout');如上例所示,当用户开始结账时,我们会把购物车 / 订单关联的所有 Paddle price identifier 传给 checkout 方法。当然,将这些条目与“购物车”或订单关联起来的责任仍然由你的应用承担。我们还通过 customData 方法,将订单 ID 提供给 Paddle Checkout Overlay。
显然,你很可能希望在客户完成结账后,将订单标记为“已完成”。要实现这一点,你可以监听 Paddle 分发、并由 Cashier 以事件形式抛出的 webhook,将订单信息存入数据库。
首先,监听 Cashier 分发的 TransactionCompleted 事件。通常,你应在应用 AppServiceProvider 的 boot 方法中注册该事件监听器:
php
use App\Listeners\CompleteOrder;
use Illuminate\Support\Facades\Event;
use Laravel\Paddle\Events\TransactionCompleted;
/**
* Bootstrap any application services.
*/
public function boot(): void
{
Event::listen(TransactionCompleted::class, CompleteOrder::class);
}在这个示例中,CompleteOrder 监听器可能如下所示:
php
namespace App\Listeners;
use App\Models\Order;
use Laravel\Paddle\Cashier;
use Laravel\Paddle\Events\TransactionCompleted;
class CompleteOrder
{
/**
* Handle the incoming Cashier webhook event.
*/
public function handle(TransactionCompleted $event): void
{
$orderId = $event->payload['data']['custom_data']['order_id'] ?? null;
$order = Order::findOrFail($orderId);
$order->update(['status' => 'completed']);
}
}关于 transaction.completed 事件中包含的数据,请参阅 Paddle 的相关文档。
销售订阅
NOTE
在使用 Paddle Checkout 之前,你应在 Paddle 仪表板中定义固定价格的产品。此外,你还应配置 Paddle 的 webhook 处理。
通过应用提供产品和订阅计费通常会让人望而生畏。但借助 Cashier 和 Paddle Checkout Overlay,你可以轻松构建现代且可靠的支付集成。
为了演示如何使用 Cashier 和 Paddle Checkout Overlay 销售订阅,假设有一个简单的订阅服务,提供基础版月付(price_basic_monthly)和年付(price_basic_yearly)计划。这两个价格可以在 Paddle 仪表板中归属于同一个 “Basic” 产品(pro_basic)。此外,订阅服务还可能提供一个 “Expert” 计划,对应 pro_expert。
首先,看一下客户如何订阅服务。设想客户会在应用的价格页中点击 Basic 套餐的“订阅”按钮。这个按钮会为所选计划打开一个 Paddle Checkout Overlay。首先,我们通过 checkout 方法发起一个结账会话:
php
use Illuminate\Http\Request;
Route::get('/subscribe', function (Request $request) {
$checkout = $request->user()->checkout('price_basic_monthly')
->returnTo(route('dashboard'));
return view('subscribe', ['checkout' => $checkout]);
})->name('subscribe');在 subscribe 视图中,我们会包含一个按钮来显示 Checkout Overlay。Cashier Paddle 自带 paddle-button Blade 组件;当然,你也可以手动渲染 overlay checkout:
html
<x-paddle-button :checkout="$checkout" class="px-8 py-4">
Subscribe
</x-paddle-button>现在,当点击 Subscribe 按钮时,客户即可填写支付信息并发起订阅。要知道订阅何时真正开始生效(因为某些支付方式需要几秒钟才能处理),你还应配置 Cashier 的 webhook 处理。
当客户可以开始订阅后,我们就需要限制应用的某些部分,只允许已订阅用户访问。当然,我们始终可以通过 Cashier Billable trait 提供的 subscribed 方法判断用户当前的订阅状态:
blade
@if ($user->subscribed())
<p>你已订阅。</p>
@endif我们甚至可以轻松判断用户是否订阅了特定产品或价格:
blade
@if ($user->subscribedToProduct('pro_basic'))
<p>你已订阅我们的 Basic 产品。</p>
@endif
@if ($user->subscribedToPrice('price_basic_monthly'))
<p>你已订阅我们的 Basic 月付计划。</p>
@endif构建已订阅中间件
为了方便,你可能希望创建一个middleware,用于判断传入请求是否来自已订阅用户。定义好这个中间件后,你就可以很容易地将它分配给路由,从而阻止未订阅用户访问:
php
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class Subscribed
{
/**
* Handle an incoming request.
*/
public function handle(Request $request, Closure $next): Response
{
if (! $request->user()?->subscribed()) {
// Redirect user to billing page and ask them to subscribe...
return redirect('/subscribe');
}
return $next($request);
}
}定义好中间件后,即可将其分配给路由:
php
use App\Http\Middleware\Subscribed;
Route::get('/dashboard', function () {
// ...
})->middleware([Subscribed::class]);允许客户管理计费套餐
客户当然可能希望把订阅计划切换到其他产品或“层级”。在上面的示例中,我们可能希望允许客户将套餐从月付切换到年付。为此,你需要实现类似按钮,指向下面的路由:
php
use Illuminate\Http\Request;
Route::put('/subscription/{price}/swap', function (Request $request, $price) {
$user->subscription()->swap($price); // With "$price" being "price_basic_yearly" for this example.
return redirect()->route('dashboard');
})->name('subscription.swap');除了切换套餐之外,你还需要允许客户取消订阅。与切换套餐类似,提供一个按钮指向以下路由:
php
use Illuminate\Http\Request;
Route::put('/subscription/cancel', function (Request $request, $price) {
$user->subscription()->cancel();
return redirect()->route('dashboard');
})->name('subscription.cancel');这样一来,订阅会在当前计费周期结束时取消。
NOTE
只要你已经配置了 Cashier 的 webhook 处理,Cashier 就会通过检查来自 Paddle 的传入 webhook,自动保持应用中与 Cashier 相关的数据表同步。比如,当你通过 Paddle 仪表板取消客户订阅时,Cashier 会接收对应 webhook,并在应用数据库中把该订阅标记为 “canceled”。
Checkout Sessions
大多数向客户收费的操作,都是通过 Paddle 的 Checkout Overlay 组件 或 inline checkout 完成的“checkout”。
在使用 Paddle 处理 checkout 支付之前,你应当在 Paddle checkout 设置面板中定义应用的默认 payment link。
Overlay Checkout
在显示 Checkout Overlay 组件之前,你必须先使用 Cashier 生成一个 checkout session。checkout session 会告知结账组件应执行哪种计费操作:
php
use Illuminate\Http\Request;
Route::get('/buy', function (Request $request) {
$checkout = $user->checkout('pri_34567')
->returnTo(route('dashboard'));
return view('billing', ['checkout' => $checkout]);
});Cashier 自带了一个 paddle-button Blade 组件。你可以把 checkout session 作为 prop 传给它。这样在点击按钮时,就会显示 Paddle 的 checkout 组件:
html
<x-paddle-button :checkout="$checkout" class="px-8 py-4">
Subscribe
</x-paddle-button>默认情况下,这会使用 Paddle 的默认样式显示组件。你可以添加 Paddle 支持的属性,例如 data-theme='light',来自定义组件:
html
<x-paddle-button :checkout="$checkout" class="px-8 py-4" data-theme="light">
Subscribe
</x-paddle-button>Paddle checkout 组件是异步的。当用户在组件中创建订阅后,Paddle 会向你的应用发送 webhook,以便你正确更新应用数据库中的订阅状态。因此,正确设置 webhooks以处理来自 Paddle 的状态变化非常重要。
WARNING
订阅状态变更后,对应 webhook 通常只会有极短延迟,但你仍应在应用中考虑这种情况:用户完成结账后,订阅可能不会立刻可用。
手动渲染 Overlay Checkout
你也可以不使用 Laravel 内置 Blade 组件,手动渲染 overlay checkout。首先,像前面示例那样生成 checkout session:
php
use Illuminate\Http\Request;
Route::get('/buy', function (Request $request) {
$checkout = $user->checkout('pri_34567')
->returnTo(route('dashboard'));
return view('billing', ['checkout' => $checkout]);
});接着,可以使用 Paddle.js 初始化 checkout。在这个示例中,我们会创建一个带有 paddle_button 类的链接。Paddle.js 会识别这个类,并在点击链接时显示 overlay checkout:
blade
<?php
$items = $checkout->getItems();
$customer = $checkout->getCustomer();
$custom = $checkout->getCustomData();
?>
<a
href='#!'
class='paddle_button'
data-items='{!! json_encode($items) !!}'
@if ($customer) data-customer-id='{{ $customer->paddle_id }}' @endif
@if ($custom) data-custom-data='{{ json_encode($custom) }}' @endif
@if ($returnUrl = $checkout->getReturnUrl()) data-success-url='{{ $returnUrl }}' @endif
>
Buy Product
</a>Inline Checkout
如果你不想使用 Paddle 的 “overlay” 风格 checkout 组件,Paddle 也提供了 inline checkout 的方式。虽然这种方式不能让你调整 checkout 的 HTML 字段,但可以把组件直接嵌入到应用内部。
为了便于使用,Cashier 提供了一个 paddle-checkout Blade 组件。首先,你应先生成一个 checkout session:
php
use Illuminate\Http\Request;
Route::get('/buy', function (Request $request) {
$checkout = $user->checkout('pri_34567')
->returnTo(route('dashboard'));
return view('billing', ['checkout' => $checkout]);
});然后,你可以将 checkout session 传给组件的 checkout 属性:
blade
<x-paddle-checkout :checkout="$checkout" class="w-full" />如果想调整 inline checkout 组件的高度,可以向 Blade 组件传递 height 属性:
blade
<x-paddle-checkout :checkout="$checkout" class="w-full" height="500" />关于 inline checkout 的自定义选项,请进一步参考 Paddle 关于 Inline Checkout 与 checkout 可用设置 的文档。
手动渲染 Inline Checkout
你也可以不使用 Laravel 内置 Blade 组件,手动渲染 inline checkout。首先,像前面示例那样生成 checkout session:
php
use Illuminate\Http\Request;
Route::get('/buy', function (Request $request) {
$checkout = $user->checkout('pri_34567')
->returnTo(route('dashboard'));
return view('billing', ['checkout' => $checkout]);
});接着,可以使用 Paddle.js 初始化 checkout。这个示例使用的是 Alpine.js;当然,你也可以根据自己的前端栈进行调整:
blade
<?php
$options = $checkout->options();
$options['settings']['frameTarget'] = 'paddle-checkout';
$options['settings']['frameInitialHeight'] = 366;
?>
<div class="paddle-checkout" x-data="{}" x-init="
Paddle.Checkout.open(@json($options));
">
</div>Guest Checkouts
有时,你可能需要为那些不需要应用账号的用户创建 checkout session。你可以使用 guest 方法实现:
php
use Illuminate\Http\Request;
use Laravel\Paddle\Checkout;
Route::get('/buy', function (Request $request) {
$checkout = Checkout::guest(['pri_34567'])
->returnTo(route('home'));
return view('billing', ['checkout' => $checkout]);
});然后,你可以将 checkout session 提供给 Paddle 按钮 或 inline checkout Blade 组件。
价格预览
Paddle 允许你按货币自定义价格,本质上就是可以为不同国家配置不同价格。Cashier Paddle 允许你通过 previewPrices 方法获取这些价格。该方法接收你想获取价格的 price ID:
php
use Laravel\Paddle\Cashier;
$prices = Cashier::previewPrices(['pri_123', 'pri_456']);货币会根据请求的 IP 地址决定;不过,你也可以可选地显式指定一个国家来获取对应价格:
php
use Laravel\Paddle\Cashier;
$prices = Cashier::previewPrices(['pri_123', 'pri_456'], ['address' => [
'country_code' => 'BE',
'postal_code' => '1234',
]]);获取价格后,你可以按需要任意展示:
blade
<ul>
@foreach ($prices as $price)
<li>{{ $price->product['name'] }} - {{ $price->total() }}</li>
@endforeach
</ul>你也可以分别显示小计价格与税额:
blade
<ul>
@foreach ($prices as $price)
<li>{{ $price->product['name'] }} - {{ $price->subtotal() }}(+ {{ $price->tax() }} 税费)</li>
@endforeach
</ul>更多信息请查看 Paddle 关于价格预览的 API 文档。
客户价格预览
如果用户已经是 customer,并且你希望显示适用于该 customer 的价格,也可以直接从 customer 实例获取价格:
php
use App\Models\User;
$prices = User::find(1)->previewPrices(['pri_123', 'pri_456']);Cashier 内部会使用该用户的 customer ID,以用户所在货币获取价格。例如,居住在美国的用户会看到美元价格,而居住在比利时的用户会看到欧元价格。如果找不到匹配货币,则会使用产品的默认货币。你可以在 Paddle 控制面板中自定义产品或订阅计划的所有价格。
折扣
你还可以选择显示折扣后的价格。调用 previewPrices 方法时,可通过 discount_id 选项提供折扣 ID:
php
use Laravel\Paddle\Cashier;
$prices = Cashier::previewPrices(['pri_123', 'pri_456'], [
'discount_id' => 'dsc_123'
]);然后,显示计算后的价格:
blade
<ul>
@foreach ($prices as $price)
<li>{{ $price->product['name'] }} - {{ $price->total() }}</li>
@endforeach
</ul>Customers
Customer 默认值
Cashier 允许你在创建 checkout session 时,为 customer 定义一些有用的默认值。设置这些默认值后,系统可以预填 customer 的邮箱地址和姓名,让用户直接进入结账组件的支付环节。你可以通过在 billable 模型上覆盖以下方法来设置这些默认值:
php
/**
* Get the customer's name to associate with Paddle.
*/
public function paddleName(): string|null
{
return $this->name;
}
/**
* Get the customer's email address to associate with Paddle.
*/
public function paddleEmail(): string|null
{
return $this->email;
}这些默认值会被用于 Cashier 中每一个会生成 checkout session 的操作。
获取 Customers
你可以使用 Cashier::findBillable 方法,通过 Paddle Customer ID 获取 customer。该方法会返回一个 billable 模型实例:
php
use Laravel\Paddle\Cashier;
$user = Cashier::findBillable($customerId);创建 Customers
有时,你可能希望在不开始订阅的情况下先创建一个 Paddle customer。你可以使用 createAsCustomer 方法实现:
php
$customer = $user->createAsCustomer();该方法会返回一个 Laravel\Paddle\Customer 实例。customer 在 Paddle 中创建完成后,你可以稍后再开始订阅。你还可以提供一个可选的 $options 数组,以传入 Paddle API 支持的其他customer 创建参数:
php
$customer = $user->createAsCustomer($options);订阅
创建订阅
要创建订阅,首先从数据库中取出 billable 模型实例,通常会是 App\Models\User 实例。拿到模型实例后,即可使用 subscribe 方法创建该模型的 checkout session:
php
use Illuminate\Http\Request;
Route::get('/user/subscribe', function (Request $request) {
$checkout = $request->user()->subscribe($premium = 'pri_123', 'default')
->returnTo(route('home'));
return view('billing', ['checkout' => $checkout]);
});传给 subscribe 方法的第一个参数是用户订阅的具体价格。这个值应与 Paddle 中该 price 的 identifier 对应。returnTo 方法接收一个 URL,用户成功完成结账后会被重定向到这里。传给 subscribe 方法的第二个参数应是该订阅在应用内部使用的 “type”。如果应用只提供一种订阅,你可以将其命名为 default 或 primary。这个订阅类型仅供应用内部使用,不应展示给用户。此外,它不应包含空格,并且在订阅创建后不应再更改。
你还可以通过 customData 方法为订阅提供一组自定义 metadata:
php
$checkout = $request->user()->subscribe($premium = 'pri_123', 'default')
->customData(['key' => 'value'])
->returnTo(route('home'));创建好订阅 checkout session 后,你可以将其传给 Cashier Paddle 附带的 paddle-button Blade 组件:
blade
<x-paddle-button :checkout="$checkout" class="px-8 py-4">
Subscribe
</x-paddle-button>在用户完成结账后,Paddle 会分发一个 subscription_created webhook。Cashier 会接收该 webhook,并为客户创建订阅。为了确保应用能正确接收和处理所有 webhook,请确保你已经正确设置 webhook 处理。
检查订阅状态
一旦用户在你的应用中完成订阅,你就可以通过多种便捷方法检查其订阅状态。首先,subscribed 方法会在用户拥有有效订阅时返回 true,即使该订阅当前仍处于试用期:
php
if ($user->subscribed()) {
// ...
}如果你的应用提供多种订阅,则可在调用 subscribed 方法时指定订阅:
php
if ($user->subscribed('default')) {
// ...
}subscribed 方法也非常适合作为路由中间件的判断条件,从而基于用户订阅状态过滤对路由和控制器的访问:
php
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class EnsureUserIsSubscribed
{
/**
* Handle an incoming request.
*
* @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next
*/
public function handle(Request $request, Closure $next): Response
{
if ($request->user() && ! $request->user()->subscribed()) {
// This user is not a paying customer...
return redirect('/billing');
}
return $next($request);
}
}如果你想判断用户是否仍在试用期内,可以使用 onTrial 方法。这个方法可用于决定是否向用户显示仍处于试用期的提示:
php
if ($user->subscription()->onTrial()) {
// ...
}subscribedToPrice 方法可用于根据给定 Paddle price ID,判断用户是否订阅了指定计划。在下面的示例中,我们会判断用户的 default 订阅是否正在使用月付价格:
php
if ($user->subscribedToPrice($monthly = 'pri_123', 'default')) {
// ...
}recurring 方法可用于判断用户当前是否处于有效订阅状态,并且不在试用期或宽限期内:
php
if ($user->subscription()->recurring()) {
// ...
}已取消订阅状态
若要判断用户是否曾是活跃订阅者但后来取消了订阅,可以使用 canceled 方法:
php
if ($user->subscription()->canceled()) {
// ...
}你还可以判断用户是否已经取消订阅,但仍处于完全到期前的“宽限期”中。例如,如果用户在 3 月 5 日取消了原本计划于 3 月 10 日到期的订阅,那么直到 3 月 10 日为止,用户都处于“宽限期”。此外,在这段时间内,subscribed 方法仍然会返回 true:
php
if ($user->subscription()->onGracePeriod()) {
// ...
}逾期状态
如果某个订阅发生支付失败,它会被标记为 past_due。当订阅处于这种状态时,直到 customer 更新支付信息后,它都不会变回活跃状态。你可以在订阅实例上使用 pastDue 方法来判断订阅是否处于逾期状态:
php
if ($user->subscription()->pastDue()) {
// ...
}当订阅处于 past_due 状态时,你应提示用户更新支付信息。
如果你希望 past_due 状态下的订阅仍然被视为有效,可以使用 Cashier 提供的 keepPastDueSubscriptionsActive 方法。通常,该方法应在 AppServiceProvider 的 register 方法中调用:
php
use Laravel\Paddle\Cashier;
/**
* Register any application services.
*/
public function register(): void
{
Cashier::keepPastDueSubscriptionsActive();
}WARNING
当订阅处于 past_due 状态时,在支付信息更新之前,订阅无法被修改。因此,当订阅处于 past_due 状态时,swap 和 updateQuantity 方法都会抛出异常。
订阅查询作用域
大多数订阅状态也都提供了查询作用域,因此你可以方便地查询数据库中处于指定状态的订阅:
php
// Get all valid subscriptions...
$subscriptions = Subscription::query()->valid()->get();
// Get all of the canceled subscriptions for a user...
$subscriptions = $user->subscriptions()->canceled()->get();完整的可用作用域列表如下:
php
Subscription::query()->valid();
Subscription::query()->onTrial();
Subscription::query()->expiredTrial();
Subscription::query()->notOnTrial();
Subscription::query()->active();
Subscription::query()->recurring();
Subscription::query()->pastDue();
Subscription::query()->paused();
Subscription::query()->notPaused();
Subscription::query()->onPausedGracePeriod();
Subscription::query()->notOnPausedGracePeriod();
Subscription::query()->canceled();
Subscription::query()->notCanceled();
Subscription::query()->onGracePeriod();
Subscription::query()->notOnGracePeriod();订阅的一次性收费
订阅一次性收费允许你在订阅之外,对订阅用户追加一次性收费。调用 charge 方法时,你必须提供一个或多个 price ID:
php
// Charge a single price...
$response = $user->subscription()->charge('pri_123');
// Charge multiple prices at once...
$response = $user->subscription()->charge(['pri_123', 'pri_456']);charge 方法并不会立刻向客户收费,而是会等到订阅的下一个计费周期。如果你希望立即向客户收费,可以改用 chargeAndInvoice 方法:
php
$response = $user->subscription()->chargeAndInvoice('pri_123');更新支付信息
Paddle 总是按订阅维度保存支付方式。如果你想更新某个订阅的默认支付方式,应使用订阅模型上的 redirectToUpdatePaymentMethod 方法,将客户重定向到 Paddle 托管的支付方式更新页面:
php
use Illuminate\Http\Request;
Route::get('/update-payment-method', function (Request $request) {
$user = $request->user();
return $user->subscription()->redirectToUpdatePaymentMethod();
});当用户更新信息后,Paddle 会分发 subscription_updated webhook,订阅详情会在应用数据库中被更新。
切换计划
当用户订阅了你的应用后,可能会希望切换到新的订阅计划。若要更新用户的订阅计划,应将 Paddle price 的 identifier 传给订阅的 swap 方法:
php
use App\Models\User;
$user = User::find(1);
$user->subscription()->swap($premium = 'pri_456');如果你希望切换计划后立即向用户开票,而不是等到下一个计费周期,可以使用 swapAndInvoice 方法:
php
$user = User::find(1);
$user->subscription()->swapAndInvoice($premium = 'pri_456');按比例计费
默认情况下,Paddle 在切换计划时会按比例结算费用。你可以使用 noProrate 方法,在切换订阅时不进行按比例计费:
php
$user->subscription('default')->noProrate()->swap($premium = 'pri_456');如果你希望关闭按比例计费并立即向客户开票,可以将 swapAndInvoice 与 noProrate 组合使用:
php
$user->subscription('default')->noProrate()->swapAndInvoice($premium = 'pri_456');或者,如果你不想因这次订阅变更向客户收费,可以使用 doNotBill 方法:
php
$user->subscription('default')->doNotBill()->swap($premium = 'pri_456');有关 Paddle 的按比例计费策略,请参阅 Paddle 的proration 文档。
订阅数量
有时订阅会受到“数量”的影响。例如,一个项目管理应用可能按每个项目每月 10 美元收费。要方便地增加或减少订阅数量,可使用 incrementQuantity 和 decrementQuantity 方法:
php
$user = User::find(1);
$user->subscription()->incrementQuantity();
// Add five to the subscription's current quantity...
$user->subscription()->incrementQuantity(5);
$user->subscription()->decrementQuantity();
// Subtract five from the subscription's current quantity...
$user->subscription()->decrementQuantity(5);你也可以使用 updateQuantity 方法直接设置具体数量:
php
$user->subscription()->updateQuantity(10);可以使用 noProrate 方法,在不按比例计费的情况下更新订阅数量:
php
$user->subscription()->noProrate()->updateQuantity(10);多产品订阅的数量
如果你的订阅是多产品订阅,那么在调用增减数量方法时,应将你希望增减数量的那个 price ID 作为第二个参数传入:
php
$user->subscription()->incrementQuantity(1, 'price_chat');多产品订阅
多产品订阅允许你把多个计费产品附加到同一个订阅中。例如,设想你在构建一个客服 “helpdesk” 应用,它的基础订阅价格是每月 10 美元,但还提供一个每月额外 15 美元的实时聊天附加产品。
在创建订阅 checkout session 时,你可以将价格数组作为 subscribe 方法的第一个参数,从而为订阅指定多个产品:
php
use Illuminate\Http\Request;
Route::post('/user/subscribe', function (Request $request) {
$checkout = $request->user()->subscribe([
'price_monthly',
'price_chat',
]);
return view('billing', ['checkout' => $checkout]);
});在上例中,customer 的 default 订阅将附带两个价格。两个价格都会按照各自的计费周期收费。如有需要,你还可以传入一个关联数组,以便为每个 price 指定具体数量:
php
$user = User::find(1);
$checkout = $user->subscribe('default', ['price_monthly', 'price_chat' => 5]);如果你想向现有订阅中再添加一个 price,必须使用订阅的 swap 方法。调用 swap 方法时,你还应一并传入订阅当前已有的价格和数量:
php
$user = User::find(1);
$user->subscription()->swap(['price_chat', 'price_original' => 2]);上面的示例会添加新的 price,但客户不会在本次操作时立即被收费,而会等到下一个计费周期。如果你希望立即向客户开票,可以使用 swapAndInvoice 方法:
php
$user->subscription()->swapAndInvoice(['price_chat', 'price_original' => 2]);你也可以通过 swap 方法并省略希望移除的 price,从订阅中移除价格:
php
$user->subscription()->swap(['price_original' => 2]);WARNING
你不能移除订阅中的最后一个 price。如果需要这样做,应直接取消订阅。
多个订阅
Paddle 允许客户同时拥有多个订阅。例如,你可能经营一家健身房,提供游泳订阅和举重订阅,而且每个订阅都有不同的定价。当然,客户应当可以订阅其中一个或两个计划。
当应用创建订阅时,可以将订阅类型作为第二个参数传给 subscribe 方法。这个类型可以是任意字符串,用于表示用户发起的是哪种订阅:
php
use Illuminate\Http\Request;
Route::post('/swimming/subscribe', function (Request $request) {
$checkout = $request->user()->subscribe($swimmingMonthly = 'pri_123', 'swimming');
return view('billing', ['checkout' => $checkout]);
});在这个示例中,我们为客户发起了一个月付游泳订阅。以后客户可能希望切换到年付订阅。调整客户订阅时,我们只需要切换 swimming 订阅上的价格即可:
php
$user->subscription('swimming')->swap($swimmingYearly = 'pri_456');当然,你也可以完全取消该订阅:
php
$user->subscription('swimming')->cancel();暂停订阅
若要暂停某个订阅,请在用户的订阅上调用 pause 方法:
php
$user->subscription()->pause();当订阅被暂停时,Cashier 会自动在数据库中设置 paused_at 列。该列用于判断 paused 方法何时开始返回 true。例如,如果客户在 3 月 1 日暂停一个原本计划在 3 月 5 日续费的订阅,那么直到 3 月 5 日之前,paused 方法仍会返回 false。这是因为用户通常会被允许继续使用应用直到其已支付计费周期的结束。
默认情况下,暂停会在下一个计费周期开始生效,这样客户仍可使用当前已支付周期的剩余时间。如果你希望立即暂停订阅,可以使用 pauseNow 方法:
php
$user->subscription()->pauseNow();使用 pauseUntil 方法,可以将订阅暂停到某个指定时间点:
php
$user->subscription()->pauseUntil(now()->plus(months: 1));或者,你可以使用 pauseNowUntil 方法,让订阅立即暂停到某个指定时间点:
php
$user->subscription()->pauseNowUntil(now()->plus(months: 1));你可以使用 onPausedGracePeriod 方法,判断用户是否已经暂停订阅,但仍处于“暂停宽限期”:
php
if ($user->subscription()->onPausedGracePeriod()) {
// ...
}若要恢复一个已暂停的订阅,可在订阅上调用 resume 方法:
php
$user->subscription()->resume();WARNING
订阅在暂停状态下不能被修改。如果你要切换到其他计划或更新数量,必须先恢复订阅。
取消订阅
若要取消订阅,请在用户的订阅上调用 cancel 方法:
php
$user->subscription()->cancel();当订阅被取消时,Cashier 会自动在数据库中设置 ends_at 列。该列用于判断 subscribed 方法何时开始返回 false。例如,如果客户在 3 月 1 日取消了一个原本计划在 3 月 5 日结束的订阅,那么直到 3 月 5 日之前,subscribed 方法仍会返回 true。这也是因为用户通常会被允许继续使用应用直到其当前计费周期结束。
你可以使用 onGracePeriod 方法,判断用户是否已经取消订阅,但仍处于“宽限期”:
php
if ($user->subscription()->onGracePeriod()) {
// ...
}如果你希望立即取消订阅,可以在订阅上调用 cancelNow 方法:
php
$user->subscription()->cancelNow();如果你希望阻止一个处于宽限期的订阅继续取消,可以调用 stopCancelation 方法:
php
$user->subscription()->stopCancelation();WARNING
Paddle 的订阅在取消后无法恢复。如果客户希望恢复订阅,他们必须重新创建一个新订阅。
订阅试用
预先收集支付方式
如果你希望在给客户提供试用期的同时,预先收集支付方式信息,则应在 Paddle 仪表板中为客户要订阅的 price 设置试用时间。然后,像平时一样发起 checkout session:
php
use Illuminate\Http\Request;
Route::get('/user/subscribe', function (Request $request) {
$checkout = $request->user()
->subscribe('pri_monthly')
->returnTo(route('home'));
return view('billing', ['checkout' => $checkout]);
});当应用收到 subscription_created 事件后,Cashier 会同时在应用数据库的订阅记录中设置试用结束时间,并通知 Paddle 在此日期之后才开始向客户收费。
WARNING
如果在试用结束日前客户没有取消订阅,那么试用一到期就会被收费,因此你应确保通知用户其试用结束日期。
你可以使用用户实例上的 onTrial 方法来判断用户是否仍处于试用期:
php
if ($user->onTrial()) {
// ...
}若要判断现有试用是否已经结束,可以使用 hasExpiredTrial 方法:
php
if ($user->hasExpiredTrial()) {
// ...
}若要判断用户是否针对某个特定订阅类型处于试用期,可以将类型传给 onTrial 或 hasExpiredTrial 方法:
php
if ($user->onTrial('default')) {
// ...
}
if ($user->hasExpiredTrial('default')) {
// ...
}不预先收集支付方式
如果你希望提供试用期,但不提前收集用户的支付方式信息,则可以将与用户关联的 customer 记录上的 trial_ends_at 列设置为你希望的试用结束时间。这通常在用户注册时完成:
php
use App\Models\User;
$user = User::create([
// ...
]);
$user->createAsCustomer([
'trial_ends_at' => now()->plus(days: 10)
]);Cashier 将这种试用称为 “generic trial”,因为它并不绑定到现有订阅上。如果当前日期尚未超过 trial_ends_at 的值,则 User 实例上的 onTrial 方法会返回 true:
php
if ($user->onTrial()) {
// User is within their trial period...
}当你准备好为该用户创建真正的订阅时,就可以像平常一样使用 subscribe 方法:
php
use Illuminate\Http\Request;
Route::get('/user/subscribe', function (Request $request) {
$checkout = $request->user()
->subscribe('pri_monthly')
->returnTo(route('home'));
return view('billing', ['checkout' => $checkout]);
});要获取用户的试用结束日期,可以使用 trialEndsAt 方法。如果用户处于试用期,该方法会返回一个 Carbon 日期实例;如果不在试用期,则返回 null。如果你想获取某个非默认订阅类型的试用结束日期,也可以传入可选订阅类型参数:
php
if ($user->onTrial('default')) {
$trialEndsAt = $user->trialEndsAt();
}如果你想明确知道用户是否处于 “generic” 试用期、且尚未创建真正订阅,可以使用 onGenericTrial 方法:
php
if ($user->onGenericTrial()) {
// User is within their "generic" trial period...
}延长或激活试用
你可以在订阅上调用 extendTrial 方法,并指定试用结束的时间点,以延长现有试用期:
php
$user->subscription()->extendTrial(now()->plus(days: 5));或者,也可以通过在订阅上调用 activate 方法,立即结束试用并激活订阅:
php
$user->subscription()->activate();处理 Paddle Webhooks
Paddle 可以通过 webhook 向你的应用通知多种事件。默认情况下,Cashier service provider 已经注册了一个指向 Cashier webhook controller 的路由。该 controller 会处理所有传入的 webhook 请求。
默认情况下,这个 controller 会自动处理:因多次收费失败而取消订阅、订阅更新,以及支付方式变更。不过,正如后文会看到的那样,你也可以扩展这个 controller,以处理任意 Paddle webhook 事件。
为确保应用可以处理 Paddle webhooks,请务必在 Paddle 控制面板中配置 webhook URL。默认情况下,Cashier 的 webhook controller 响应 /paddle/webhook URL 路径。在 Paddle 控制面板中,你应启用的全部 webhook 包括:
- Customer Updated
- Transaction Completed
- Transaction Updated
- Subscription Created
- Subscription Updated
- Subscription Paused
- Subscription Canceled
WARNING
请确保使用 Cashier 自带的webhook 签名校验中间件来保护传入请求。
Webhooks 与 CSRF 保护
由于 Paddle webhook 需要绕过 Laravel 的 CSRF 保护,你应确保 Laravel 不会尝试校验传入 Paddle webhook 的 CSRF token。为此,你应在应用的 bootstrap/app.php 文件中,将 paddle/* 排除在 CSRF 保护之外:
php
->withMiddleware(function (Middleware $middleware): void {
$middleware->preventRequestForgery(except: [
'paddle/*',
]);
})Webhooks 与本地开发
为了让 Paddle 在本地开发时也能向你的应用发送 webhook,你需要通过站点共享服务将应用暴露到外网,例如 Ngrok 或 Expose。如果你在本地使用 Laravel Sail 开发,也可以使用 Sail 的站点共享命令。
定义 Webhook 事件处理器
Cashier 会自动处理因收费失败而取消订阅等常见 Paddle webhook。不过,如果你还有其他需要处理的 webhook 事件,可以监听 Cashier 分发的以下事件来实现:
Laravel\Paddle\Events\WebhookReceivedLaravel\Paddle\Events\WebhookHandled
这两个事件都包含 Paddle webhook 的完整 payload。例如,如果你想处理 transaction.billed webhook,可以注册一个listener来处理它:
php
<?php
namespace App\Listeners;
use Laravel\Paddle\Events\WebhookReceived;
class PaddleEventListener
{
/**
* Handle received Paddle webhooks.
*/
public function handle(WebhookReceived $event): void
{
if ($event->payload['event_type'] === 'transaction.billed') {
// Handle the incoming event...
}
}
}Cashier 还会根据接收到的 webhook 类型分发专用事件。除了 Paddle 的完整 payload 之外,这些事件还包含处理 webhook 时所使用的相关模型,例如 billable 模型、订阅或收据:
Laravel\Paddle\Events\CustomerUpdatedLaravel\Paddle\Events\TransactionCompletedLaravel\Paddle\Events\TransactionUpdatedLaravel\Paddle\Events\SubscriptionCreatedLaravel\Paddle\Events\SubscriptionUpdatedLaravel\Paddle\Events\SubscriptionPausedLaravel\Paddle\Events\SubscriptionCanceled
你还可以通过在应用 .env 文件中定义 CASHIER_WEBHOOK 环境变量,来覆盖默认的内置 webhook 路由。这个值应是 webhook 路由的完整 URL,并且必须与 Paddle 控制面板中设置的 URL 一致:
ini
CASHIER_WEBHOOK=https://example.com/my-paddle-webhook-url校验 Webhook 签名
为了保证 webhook 安全,你可以使用 Paddle 的 webhook 签名。为了方便,Cashier 已自动内置了一个中间件,用于验证传入的 Paddle webhook 请求是否合法。
要启用 webhook 校验,请确保在应用 .env 文件中定义了 PADDLE_WEBHOOK_SECRET 环境变量。这个 webhook secret 可在 Paddle 账号仪表板中获取。
一次性收费
产品收费
如果你想为某个 customer 发起产品购买,可以在 billable 模型实例上使用 checkout 方法,为这次购买生成 checkout session。checkout 方法接受一个或多个 price ID。如有必要,也可以使用关联数组来为正在购买的产品提供数量信息:
php
use Illuminate\Http\Request;
Route::get('/buy', function (Request $request) {
$checkout = $request->user()->checkout(['pri_tshirt', 'pri_socks' => 5]);
return view('buy', ['checkout' => $checkout]);
});在生成 checkout session 后,你可以使用 Cashier 提供的 paddle-button Blade 组件,让用户打开 Paddle checkout 组件并完成购买:
blade
<x-paddle-button :checkout="$checkout" class="px-8 py-4">
Buy
</x-paddle-button>checkout session 提供了 customData 方法,允许你将任意自定义数据传递到底层交易创建流程。请参考 Paddle 文档,了解传递自定义数据时可用的选项:
php
$checkout = $user->checkout('pri_tshirt')
->customData([
'custom_option' => $value,
]);退款交易
退款交易会将退款金额退回到 customer 在购买时使用的支付方式上。如果你需要为某笔 Paddle 购买执行退款,可以在 Cashier\Paddle\Transaction 模型上使用 refund 方法。该方法的第一个参数是退款原因,第二个参数是一个或多个需要退款的 price ID,也可以是一个带可选金额的关联数组。你可以通过 billable 模型上的 transactions 方法获取指定模型的交易。
例如,假设我们要针对某笔交易中的 pri_123 和 pri_456 进行退款。我们希望对 pri_123 全额退款,但对 pri_456 只退 2 美元:
php
use App\Models\User;
$user = User::find(1);
$transaction = $user->transactions()->first();
$response = $transaction->refund('Accidental charge', [
'pri_123', // Fully refund this price...
'pri_456' => 200, // Only partially refund this price...
]);上面的示例是针对交易中的特定 line item 退款。如果你想整笔交易全部退款,只需提供一个原因即可:
php
$response = $transaction->refund('Accidental charge');关于退款的更多信息,请参阅 Paddle 的退款文档。
WARNING
退款在完全处理之前,始终需要先经过 Paddle 审批。
为交易记账余额
与退款类似,你也可以为交易记账余额。记账余额会将资金添加到 customer 的余额中,以便其将来购买时使用。记账余额只能应用于手动收款交易,不能用于自动收款交易(例如订阅),因为订阅的余额调整由 Paddle 自动处理:
php
$transaction = $user->transactions()->first();
// Credit a specific line item fully...
$response = $transaction->credit('Compensation', 'pri_123');更多信息请参阅 Paddle 关于记账余额的文档。
WARNING
余额记账只能应用于手动收款交易。自动收款交易的余额调整由 Paddle 自行处理。
Transactions
你可以通过 billable 模型的 transactions 属性,轻松获取该模型的交易数组:
php
use App\Models\User;
$user = User::find(1);
$transactions = $user->transactions;Transactions 表示对产品和购买的付款,并伴随发票。只有已完成的 transaction 才会被存储在应用数据库中。
列出某个 customer 的交易时,你可以使用 transaction 实例上的方法显示相关支付信息。例如,你可能希望将每笔交易都列在表格中,让用户能够方便地下载任何发票:
html
<table>
@foreach ($transactions as $transaction)
<tr>
<td>{{ $transaction->billed_at->toFormattedDateString() }}</td>
<td>{{ $transaction->total() }}</td>
<td>{{ $transaction->tax() }}</td>
<td><a href="{{ route('download-invoice', $transaction->id) }}" target="_blank">下载</a></td>
</tr>
@endforeach
</table>download-invoice 路由可以写成如下形式:
php
use Illuminate\Http\Request;
use Laravel\Paddle\Transaction;
Route::get('/download-invoice/{transaction}', function (Request $request, Transaction $transaction) {
return $transaction->redirectToInvoicePdf();
})->name('download-invoice');历史与未来付款
你可以使用 lastPayment 和 nextPayment 方法,获取并显示某个周期性订阅的历史付款或未来付款:
php
use App\Models\User;
$user = User::find(1);
$subscription = $user->subscription();
$lastPayment = $subscription->lastPayment();
$nextPayment = $subscription->nextPayment();这两个方法都会返回一个 Laravel\Paddle\Payment 实例。不过,当 transaction 尚未通过 webhook 同步时,lastPayment 会返回 null;而当计费周期已经结束(例如订阅被取消)时,nextPayment 会返回 null:
blade
下次付款:{{ $nextPayment->amount() }},到期日 {{ $nextPayment->date()->format('d/m/Y') }}测试
在测试时,你应手动测试整个计费流程,以确保集成工作正常。
对于自动化测试,包括在 CI 环境中执行的测试,你可以使用 Laravel HTTP Client 来 fake 掉对 Paddle 发起的 HTTP 调用。虽然这并不会测试 Paddle 的真实响应,但它提供了一种无需实际调用 Paddle API 即可测试应用的方法。