Laravel Spark 如何实现「All My Teams」统一计费?
结论
Laravel Spark 通常不原生支持「多个团队共享一个订阅」这种层级计费模式。
Spark 的基本逻辑是一个可计费实体对应一份订阅。可计费实体可以是用户,也可以是团队,但 Spark 不会自动把多个团队组合成更高一级的计费对象。具体来说:
- 个人计费时,订阅归属于用户。
- 团队计费时,订阅归属于某个团队,成员通过该团队获得服务权限。
- 如果需要「All My Teams」计费,则要额外建立父级实体,例如
Organization、Account或Tenant,由这个实体持有订阅,其下关联多个团队。
团队计费也不等于为团队中的每个用户分别创建订阅。更常见的做法是由团队持有一份订阅,再根据团队成员关系和套餐限制决定成员能否使用服务。
不同代际和版本的 Spark 在扩展方式上差异较大,不能假定某个具体版本一定提供可直接配置的 Organization 或多团队聚合功能。不过,从数据模型和计费架构来看,这类需求已经有比较成熟的通用实现方式。
推荐的数据模型
不要直接把管理员用户设为所有团队的计费主体。更稳妥的设计是增加一个独立的组织或客户账户:
Organization
├── Subscription
├── Billing administrators
├── Team A
│ ├── User 1
│ └── User 2
├── Team B
│ ├── User 3
│ └── User 4
└── Team C
└── User 5
对应关系可以设计为:
organizations
id
name
owner_id
teams
id
organization_id
name
organization_user
organization_id
user_id
role
team_user
team_id
user_id
role
订阅、支付方式、账单地址和 Stripe Customer 等计费信息都归属于 Organization,而不是某个用户或单个团队。
这样设计的原因很直接:管理员可能离职、被替换或失去管理权限,但组织及其订阅仍需继续存在。
整体实现步骤
1. 创建父级计费实体
使用 Organization 作为多个团队的共同父级:
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Laravel\Cashier\Billable;
class Organization extends Model
{
use Billable;
protected $fillable = [
'name',
'owner_id',
];
public function teams()
{
return $this->hasMany(Team::class);
}
public function owner()
{
return $this->belongsTo(User::class, 'owner_id');
}
public function members()
{
return $this->belongsToMany(User::class)
->withPivot('role')
->withTimestamps();
}
}
这段代码展示的是一种通用设计,即让 Organization 成为 Laravel Cashier 的可计费模型。它能否直接接入 Spark 的账单界面,要看当前 Spark 版本及其对可计费模型的配置能力。
2. 让团队归属于组织
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class Team extends Model
{
protected $fillable = [
'organization_id',
'name',
];
public function organization()
{
return $this->belongsTo(Organization::class);
}
public function users()
{
return $this->belongsToMany(User::class)
->withTimestamps();
}
}
创建团队时,应通过当前组织上下文建立关系,不要直接接受未经校验的 organization_id:
$team = $organization->teams()->create([
'name' => $validated['name'],
]);
3. 在父级实体上创建订阅
订阅应创建在组织上:
$organization
->newSubscription('default', $priceId)
->create($paymentMethod);
权限检查也应沿着「用户 → 团队 → 组织 → 订阅」的关系进行:
$hasAccess = $user->teams()
->whereHas('organization', function ($query) {
$query->whereHas('subscriptions', function ($subscriptionQuery) {
$subscriptionQuery
->where('type', 'default')
->where('stripe_status', 'active');
});
})
->exists();
实际应用中,最好把这类判断封装到 Policy、Gate 或领域服务中,避免在控制器和视图里重复查询订阅状态。例如:
public function organizationHasAccess(
User $user,
Organization $organization
): bool {
return $organization->members()
->whereKey($user->getKey())
->exists()
&& $organization->subscribed('default');
}
套餐数量如何计算
统一订阅只解决了「谁付款」的问题,还要明确具体按什么收费。常见方案有以下三种。
按组织固定收费
一个组织购买一个套餐,套餐允许创建一定数量的团队和成员:
if ($organization->teams()->count() >= $plan->team_limit) {
throw ValidationException::withMessages([
'team' => '当前套餐允许创建的团队数量已达上限。',
]);
}
这种模式实现简单,账单也容易理解。
按成员席位收费
将所有团队中的有效成员合并去重,然后把人数作为订阅数量:
$seatCount = User::query()
->whereHas('teams', function ($query) use ($organization) {
$query->where('organization_id', $organization->id);
})
->distinct()
->count('users.id');
$organization
->subscription('default')
->updateQuantity(max(1, $seatCount));
同一个用户可能加入同一组织下的多个团队,通常只能计算一个席位,否则会造成重复收费。
按团队数量收费
订阅数量等于组织下的有效团队数:
$teamCount = $organization->teams()
->where('billing_status', 'active')
->count();
$organization
->subscription('default')
->updateQuantity(max(1, $teamCount));
这种方式适合代理商、门店、项目空间或分支机构等场景。
如果费用同时取决于团队数和成员数,可以使用多个订阅项目,也可以交给 Stripe 的分级价格或用量计费等能力处理。具体能采用哪种方式,取决于支付平台配置和项目使用的 Cashier 版本。
Spark 在其中承担什么角色
Spark 仍可用于订阅购买、支付方式管理和发票展示等通用账单功能,但以下业务逻辑需要应用自行处理:
- 组织、团队和成员之间的层级关系;
- 哪些人可以管理组织账单;
- 用户通过哪个组织获得服务权限;
- 如何计算团队数或席位数;
- 成员加入、退出或跨团队移动时,如何更新订阅数量;
- 套餐降级后,如何处理超额的团队或成员。
如果当前 Spark 版本只能围绕 User 或既有的 Team 模型提供账单门户,可能需要自定义账单页面,直接通过 Laravel Cashier 管理 Organization 的订阅。不要为了保留 Spark 的默认界面,把组织订阅强行挂到某个管理员用户身上。
是否有现成方案
这不是 Spark 中可以直接开启的标准模式,也没有适用于所有项目的通用「Team of Teams」开关。成熟的是整体架构模式,而不是某个固定代码包:
- 使用
Organization或Account作为账单主体。 - 把团队作为该账单主体下的资源。
- 通过角色系统区分账单管理员、团队管理员和普通成员。
- 根据成员数、团队数或固定套餐同步订阅数量。
- 使用统一的授权服务判断用户能否继承组织的订阅权益。
第三方多租户或权限组件可以协助处理组织隔离、当前租户和角色权限,但通常不会替项目确定计费规则。计费主体、席位去重、套餐限制和降级行为仍需按业务要求实现。
需要提前决定的边界条件
实现前应先明确以下规则,否则后期很容易出现账单与权限状态不一致的问题:
- 一个团队是否只能属于一个组织;
- 一个用户能否属于多个组织;
- 同一用户加入多个团队时,是否只占一个席位;
- 已受邀但尚未接受邀请的用户是否计费;
- 停用、暂停或只读成员是否计费;
- 删除团队或移除成员后,是立即减少订阅数量,还是在下个账期生效;
- 订阅欠费或取消后,所有团队是立即停用,还是进入宽限期;
- 转移组织所有者是否会影响支付客户和历史发票;
- 并发添加成员时,如何避免席位数量同步错误。
更合适的方案不是修改 Spark 的「团队计费」概念,而是增加更高一级的 Organization 计费模型。Spark 或 Cashier 负责支付生命周期,应用自身的领域模型负责多团队归属、席位计算和授权。这样既能实现统一订阅,也便于后续维护。
备注:内容仅供参考。
