Skip to content
全部文档

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 migrate

WARNING

为确保 Cashier 正确处理所有 Paddle 事件,请记得配置 Cashier 的 webhook 处理。

Paddle 沙箱 ​

本地与预发布开发时,应注册 Paddle 沙箱账户,在无真实付款的环境中测试开发。可使用 Paddle 测试卡号模拟各种支付场景。

使用 Paddle 沙箱环境时,应在 .env 中将 PADDLE_SANDBOX 设为 true:

ini
PADDLE_SANDBOX=true

开发完成后可申请 Paddle 供应商账户。应用上线前,Paddle 需审核你的应用域名。

配置 ​

可计费模型 ​

使用 Cashier 前,须为用户模型添加 Billable 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 密钥 ​

接下来,在 .env 中配置 Paddle 密钥,可从 Paddle 控制面板获取 API 密钥:

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 沙箱环境 时 PADDLE_SANDBOX 应为 true;部署到生产并使用 Paddle 正式供应商环境时应为 false。

PADDLE_RETAIN_KEY 为可选,仅在与 Retain 配合使用 Paddle 时设置。

Paddle JS ​

Paddle 依赖其 JavaScript 库启动结账组件。可在布局 </head> 标签前放置 @paddleJS Blade 指令加载:

blade
<head>
    ...

    @paddleJS
</head>

货币配置 ​

可指定发票上金额显示所用的区域设置。Cashier 内部使用 PHP NumberFormatter 类 设置货币区域:

ini
CASHIER_CURRENCY_LOCALE=nl_BE

WARNING

要使用 en 以外的区域,请确保服务器已安装并配置 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);
}

快速入门 ​

销售产品 ​

INFO

使用 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 中,「prices」指特定产品的已定义价格。

必要时 checkout 方法会在 Paddle 自动创建客户,并将该记录关联到应用数据库中的对应用户。结账会话完成后,客户将重定向到成功页,你可向客户显示信息消息。

在 buy 视图中,我们将包含显示 Checkout Overlay 的按钮。Cashier Paddle 包含 paddle-button Blade 组件;也可手动渲染浮层结账:

html
<x-paddle-button :checkout="$checkout" class="px-8 py-4">
    Buy Product
</x-paddle-button>

向 Paddle Checkout 提供元数据 ​

销售产品时,通常通过应用自定义的 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 文档。

销售订阅 ​

INFO

使用 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 方案的「subscribe」按钮,触发所选方案的 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 组件;也可手动渲染浮层结账:

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>You are subscribed.</p>
@endif

还可轻松判断用户是否订阅了特定产品或价格:

blade
@if ($user->subscribedToProduct('pro_basic'))
    <p>You are subscribed to our Basic product.</p>
@endif

@if ($user->subscribedToPrice('price_basic_monthly'))
    <p>You are subscribed to our monthly Basic plan.</p>
@endif

构建已订阅中间件 ​

为方便起见,可创建中间件判断请求是否来自已订阅用户,并将其分配给路由以阻止未订阅用户访问:

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');

订阅将在当前计费周期结束时取消。

INFO

只要配置了 Cashier webhook 处理,Cashier 会通过检查 Paddle 传入的 webhook 自动同步应用中与 Cashier 相关的数据库表。例如,在 Paddle 控制台取消客户订阅时,Cashier 会收到 webhook 并在数据库中将订阅标记为「已取消」。

结账会话 ​

向客户计费的大多数操作通过 Paddle Checkout Overlay 组件或内联结账的「checkout」完成。

使用 Paddle 处理结账支付前,应在 Paddle 结账设置中定义应用的默认支付链接。

浮层结账 ​

显示 Checkout Overlay 组件前,须使用 Cashier 生成结账会话,以告知组件应执行的计费操作:

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 组件。可将结账会话作为「prop」传入;点击按钮后将显示 Paddle 结账组件:

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 结账组件是异步的。用户在组件内创建订阅后,Paddle 会向应用发送 webhook 以更新数据库中的订阅状态。因此务必正确配置 webhook 以应对 Paddle 的状态变化。

WARNING

订阅状态变化后,收到对应 webhook 的延迟通常很小,但应用中应考虑到用户完成结账后订阅可能不会立即可用。

手动渲染浮层结账 ​

也可不使用 Laravel 内置 Blade 组件手动渲染浮层结账。首先按先前示例生成结账会话:

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 初始化结账。此例创建带 paddle_button 类的链接,Paddle.js 会检测该类并在点击时显示浮层结账:

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>

内联结账 ​

若不想使用 Paddle「浮层」样式结账组件,Paddle 也提供内联显示选项。此方式无法调整结账 HTML 字段,但可将组件嵌入应用内。

为便于内联结账入门,Cashier 包含 paddle-checkout Blade 组件。首先应生成结账会话:

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 属性:

blade
<x-paddle-checkout :checkout="$checkout" class="w-full" />

要调整内联结账组件高度,可向 Blade 组件传入 height 属性:

blade
<x-paddle-checkout :checkout="$checkout" class="w-full" height="500" />

有关内联结账自定义选项的更多细节,请参阅 Paddle 内联结账指南与可用结账设置。

手动渲染内联结账 ​

也可不使用 Laravel 内置 Blade 组件手动渲染内联结账。首先按先前示例生成结账会话:

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 初始化结账。此例使用 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 方法:

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]);
});

然后将结账会话传给 Paddle 按钮或内联结账 Blade 组件。

价格预览 ​

Paddle 允许按货币自定义价格,即为不同国家配置不同价格。Cashier Paddle 可用 previewPrices 方法获取这些价格,该方法接受要获取的价格 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() }} tax)</li>
    @endforeach
</ul>

更多信息请参阅 Paddle 价格预览 API 文档。

客户价格预览 ​

若用户已是客户且要显示适用于该客户的价格,可直接从客户实例获取:

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>

客户 ​

客户默认值 ​

Cashier 允许在创建结账会话时为客户定义实用默认值,预填邮箱与姓名,使客户可直接进入结账组件的支付环节。可在可计费模型上覆盖以下方法设置:

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 中所有生成结账会话的操作。

获取客户 ​

可使用 Cashier::findBillable 方法按 Paddle Customer ID 获取客户,返回可计费模型实例:

php
use Laravel\Paddle\Cashier;

$user = Cashier::findBillable($customerId);

创建客户 ​

有时你可能希望在不开始订阅的情况下创建 Paddle 客户,可使用 createAsCustomer 方法:

php
$customer = $user->createAsCustomer();

返回 Laravel\Paddle\Customer 实例。在 Paddle 创建客户后,可稍后开始订阅。可提供可选 $options 数组传入 Paddle API 支持的额外客户创建参数:

php
$customer = $user->createAsCustomer($options);

订阅 ​

创建订阅 ​

要创建订阅,先从数据库获取可计费模型实例(通常为 App\Models\User),然后使用 subscribe 方法创建该模型的结账会话:

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 方法提供关于订阅的自定义元数据数组:

php
$checkout = $request->user()->subscribe($premium = 'pri_123', 'default')
    ->customData(['key' => 'value'])
    ->returnTo(route('home'));

创建订阅结账会话后,可将其传给 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。此状态下订阅不活跃,直至客户更新支付信息。可在订阅实例上使用 pastDue 判断是否逾期:

php
if ($user->subscription()->pastDue()) {
    // ...
}

订阅逾期时,应引导用户更新支付信息。

若希望 past_due 订阅仍被视为有效,可使用 Cashier 的 keepPastDueSubscriptionsActive 方法,通常在 AppServiceProvider 的 register 方法中调用:

php
use Laravel\Paddle\Cashier;

/**
 * Register any application services.
 */
public function register(): void
{
    Cashier::keepPastDueSubscriptionsActive();
}

WARNING

订阅处于 past_due 时,在更新支付信息前无法更改,因此 swap 与 updateQuantity 在 past_due 时会抛出异常。

订阅查询作用域 ​

大多数订阅状态也可作为查询作用域,便于查询处于给定状态的订阅:

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 按比例计费文档。

订阅数量 ​

有时订阅受「数量」影响。例如项目管理应用可能按项目每月收费 $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 的在线聊天附加产品。

创建订阅结账会话时,可将价格数组作为 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]);
});

上例中,客户的 default 订阅将附加两个价格,各自按对应计费周期收费。必要时可传入键值对关联数组为每个价格指定数量:

php
$user = User::find(1);

$checkout = $user->subscribe('default', ['price_monthly', 'price_chat' => 5]);

若要为现有订阅添加另一价格,须使用订阅的 swap 方法,调用时还应包含订阅当前价格与数量:

php
$user = User::find(1);

$user->subscription()->swap(['price_chat', 'price_original' => 2]);

上例会添加新价格,但客户要到下一计费周期才会被计费。若要立即计费,可使用 swapAndInvoice 方法:

php
$user->subscription()->swapAndInvoice(['price_chat', 'price_original' => 2]);

可使用 swap 方法并从参数中省略要移除的价格,从订阅中移除价格:

php
$user->subscription()->swap(['price_original' => 2]);

WARNING

不能移除订阅上的最后一个价格,应直接取消订阅。

多个订阅 ​

Paddle 允许客户同时拥有多个订阅。例如健身房提供游泳与举重订阅,定价可能不同,客户可订阅其一或两者。

应用创建订阅时,可将订阅 type 作为 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 日续费,则 paused 在 3 月 5 日前仍返回 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 日结束的订阅,则 subscribed 在 3 月 5 日前仍返回 true,因为用户通常可使用应用至计费周期结束。

可使用 onGracePeriod 方法判断用户已取消订阅但仍处于「宽限期」:

php
if ($user->subscription()->onGracePeriod()) {
    // ...
}

若要立即取消订阅,可在订阅上调用 cancelNow 方法:

php
$user->subscription()->cancelNow();

要阻止处于宽限期的订阅被取消,可调用 stopCancelation 方法:

php
$user->subscription()->stopCancelation();

WARNING

Paddle 订阅取消后无法恢复。若客户希望恢复订阅,须创建新订阅。

订阅试用 ​

预先收集支付方式 ​

若要在预先收集支付方式的同时为客户提供试用期,应在 Paddle 控制台为客户订阅的价格上设置试用时间,然后照常发起结账会话:

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 方法提供 type:

php
if ($user->onTrial('default')) {
    // ...
}

if ($user->hasExpiredTrial('default')) {
    // ...
}

不预先收集支付方式 ​

若要在不预先收集支付方式的情况下提供试用期,可将用户关联客户记录的 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。也可传入可选订阅 type 参数以获取非默认订阅的试用结束日期:

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 Webhook ​

Paddle 可通过 webhook 向应用通知多种事件。默认情况下 Cashier 服务提供者会注册指向 Cashier webhook 控制器的路由,该控制器处理所有传入 webhook 请求。

默认该控制器会自动处理失败扣费过多而取消订阅、订阅更新与支付方式变更;但如后文所述,你可扩展该控制器处理任意 Paddle webhook 事件。

为确保应用能处理 Paddle webhook,请在 Paddle 控制面板配置 webhook URL。默认 Cashier webhook 控制器响应 /paddle/webhook 路径。应在 Paddle 控制面板启用的 webhook 完整列表如下:

  • 客户已更新
  • 交易已完成
  • 交易已更新
  • 订阅已创建
  • 订阅已更新
  • 订阅已暂停
  • 订阅已取消

WARNING

请确保使用 Cashier 内置的 webhook 签名验证 中间件保护传入请求。

Webhook 与 CSRF 保护 ​

由于 Paddle webhook 需绕过 Laravel CSRF 保护,应确保 Laravel 不验证 Paddle webhook 的 CSRF 令牌。在 bootstrap/app.php 中将 paddle/* 排除在 CSRF 保护之外:

php
->withMiddleware(function (Middleware $middleware): void {
    $middleware->validateCsrfTokens(except: [
        'paddle/*',
    ]);
})

Webhook 与本地开发 ​

本地开发时 Paddle 要向应用发送 webhook,需通过 Ngrok 或 Expose 等站点共享服务暴露应用。若使用 Laravel Sail 本地开发,可使用 Sail 的站点共享命令。

定义 Webhook 事件处理器 ​

Cashier 会自动处理扣费失败取消订阅及其他常见 Paddle webhook。若有额外 webhook 事件要处理,可监听 Cashier 派发的以下事件:

  • Laravel\Paddle\Events\WebhookReceived
  • Laravel\Paddle\Events\WebhookHandled

两个事件均包含 Paddle webhook 的完整 payload。例如要处理 transaction.billed webhook,可注册处理该事件的监听器:

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 时使用的相关模型,如可计费模型、订阅或收据:

  • Laravel\Paddle\Events\CustomerUpdated
  • Laravel\Paddle\Events\TransactionCompleted
  • Laravel\Paddle\Events\TransactionUpdated
  • Laravel\Paddle\Events\SubscriptionCreated
  • Laravel\Paddle\Events\SubscriptionUpdated
  • Laravel\Paddle\Events\SubscriptionPaused
  • Laravel\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 账户控制台获取。

单次扣费 ​

为产品扣费 ​

若要为客户发起产品购买,可在可计费模型实例上使用 checkout 方法生成购买结账会话。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]);
});

生成结账会话后,可使用 Cashier 提供的 paddle-button Blade 组件 让用户查看 Paddle 结账组件并完成购买:

blade
<x-paddle-button :checkout="$checkout" class="px-8 py-4">
    Buy
</x-paddle-button>

结账会话有 customData 方法,可向底层交易创建传递任意自定义数据。有关传递自定义数据的可用选项,请参阅 Paddle 文档:

php
$checkout = $user->checkout('pri_tshirt')
    ->customData([
        'custom_option' => $value,
    ]);

退款交易 ​

退款交易会将退款金额退回客户购买时使用的支付方式。要退款 Paddle 购买,可在 Cashier\Paddle\Transaction 模型上使用 refund 方法。第一个参数为原因,随后为一个或多个 price ID 及可选金额的关联数组。可使用 transactions 方法获取给定可计费模型的交易。

例如,假设要对价格 pri_123 与 pri_456 的特定交易退款:完全退款 pri_123,仅对 pri_456 退款两美元:

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...
]);

上例退款交易中的特定行项。若要退款整笔交易,只需提供原因:

php
$response = $transaction->refund('Accidental charge');

有关退款的更多信息,请参阅 Paddle 退款文档。

WARNING

退款在完全处理前须始终经 Paddle 批准。

交易入账 ​

与退款类似,也可对交易入账。入账会将资金加入客户余额以供未来购买使用。入账仅适用于手动收款交易,不适用于自动收款交易(如订阅),因为 Paddle 会自动处理订阅入账:

php
$transaction = $user->transactions()->first();

// Credit a specific line item fully...
$response = $transaction->credit('Compensation', 'pri_123');

更多信息请参阅 Paddle 入账文档。

WARNING

入账仅适用于手动收款交易。自动收款交易由 Paddle 自行入账。

交易 ​

可通过 transactions 属性轻松获取可计费模型交易数组:

php
use App\Models\User;

$user = User::find(1);

$transactions = $user->transactions;

交易代表产品与购买的付款,并附带发票。仅已完成的交易会存入应用数据库。

列出客户交易时,可使用交易实例的方法显示相关支付信息。例如可在表格中列出每笔交易,便于用户下载发票:

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">Download</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 实例;但 webhook 尚未同步交易时 lastPayment 返回 null,计费周期已结束(如订阅已取消)时 nextPayment 返回 null:

blade
Next payment: {{ $nextPayment->amount() }} due on {{ $nextPayment->date()->format('d/m/Y') }}

测试 ​

测试时,应手动测试计费流程以确保集成按预期工作。

对于自动化测试(包括 CI 环境),可使用 Laravel HTTP 客户端 伪造对 Paddle 的 HTTP 调用。虽不能测试 Paddle 的实际响应,但可在不实际调用 Paddle API 的情况下测试应用。