Skip to content
全部文档

Laravel Cashier (Paddle)

简介

WARNING

本文档适用于 Cashier Paddle 2.x 与 Paddle Billing 的集成。若仍使用 Paddle Classic,应使用 Cashier Paddle 1.x

Laravel Cashier PaddlePaddle 订阅计费服务提供富有表现力、流畅的接口,处理几乎所有繁琐的订阅计费代码。除基本订阅管理外,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 表,以及 subscriptionssubscription_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\AppServiceProviderboot 方法中配置:

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 提供元数据

销售产品时,通常通过应用自定义的 CartOrder 模型跟踪已完成订单与已购产品。将客户重定向到 Paddle Checkout Overlay 完成购买时,可能需要提供现有订单标识,以便客户返回应用时将已完成购买与对应订单关联。

为此,可向 checkout 方法提供自定义数据数组。假设用户开始结账时在应用中创建待处理 Order。请记住,此例中的 CartOrder 仅为示意,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 事件,通常在 AppServiceProviderboot 方法中注册监听器:

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」。若应用仅提供单一订阅,可命名为 defaultprimary。该类型仅供应用内部使用,不向用户展示,且不得包含空格,创建订阅后不应更改。

也可使用 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 方法,通常在 AppServiceProviderregister 方法中调用:

php
use Laravel\Paddle\Cashier;

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

WARNING

订阅处于 past_due 时,在更新支付信息前无法更改,因此 swapupdateQuantitypast_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');

若要禁用按比例计费并立即向客户开票,可将 swapAndInvoicenoProrate 组合使用:

php
$user->subscription('default')->noProrate()->swapAndInvoice($premium = 'pri_456');

或者,若不对订阅变更向客户计费,可使用 doNotBill 方法:

php
$user->subscription('default')->doNotBill()->swap($premium = 'pri_456');

有关 Paddle 按比例计费政策,请参阅 Paddle 按比例计费文档

订阅数量

有时订阅受「数量」影响。例如项目管理应用可能按项目每月收费 $10。要增减订阅数量,可使用 incrementQuantitydecrementQuantity 方法:

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()) {
    // ...
}

要判断用户是否处于特定订阅类型的试用期,可向 onTrialhasExpiredTrial 方法提供 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_atUser 实例的 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,需通过 NgrokExpose 等站点共享服务暴露应用。若使用 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_123pri_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');

历史与即将发生的付款

可使用 lastPaymentnextPayment 方法获取并显示客户循环订阅的历史或即将发生的付款:

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 的情况下测试应用。