Killing à-la-carte: migrating a feature-gating model to plans-only
Killing à-la-carte: migrating a feature-gating model to plans-only
终结“单点付费”:将功能授权模型迁移至“套餐制”
TL;DR Moved a SaaS from à-la-carte feature subscriptions (pay per feature) to plans-only (pay for a tier, get its features). Did it in four phases so nothing broke mid-flight: seed plans → gate on plans → migrate orgs → delete the old machinery. Lesson: model migrations are safest as expand → migrate → contract, not a big-bang swap. 简而言之: 我们将一款 SaaS 产品从“单点功能订阅”(按功能付费)迁移到了“套餐制”(购买等级,获取对应功能)。整个过程分为四个阶段,确保了运行期间不会出现故障:初始化套餐 → 基于套餐进行权限控制 → 迁移组织数据 → 删除旧代码。经验教训:模型迁移最安全的方式是“扩展 → 迁移 → 收缩”,而不是“大爆炸式”的一次性替换。
The problem: The old billing let an org subscribe to individual features à la carte. Flexible on paper, painful in practice: entitlement logic had two sources of truth (per-feature subscriptions and an implicit plan), and every gate had to check both. Time to collapse it into one model — you buy a plan, the plan carries the features. The trap with this kind of change is the temptation to rip out the old columns and ship. Do that and every in-flight subscription, every gate check, and every webhook that still speaks the old language breaks at once. 问题所在: 旧的计费系统允许组织按需订阅单个功能。这在理论上很灵活,但在实践中却很痛苦:权限逻辑存在两个事实来源(按功能订阅和隐含的套餐),每个权限检查点都必须同时校验两者。是时候将其合并为一个模型了——购买套餐,套餐包含功能。此类变更的陷阱在于,人们往往倾向于直接删掉旧字段并发布。如果这样做,所有正在进行的订阅、权限检查以及仍在使用旧逻辑的 Webhook 都会立即崩溃。
The phased plan: I ran it as four ordered migrations. Each phase is deployable on its own and leaves the app working. 分阶段计划: 我将其拆分为四个有序的迁移步骤。每个阶段都可以独立部署,并确保应用正常运行。
| Phase | What it does | Why this order |
|---|---|---|
| F1 Seed Plans | New model must exist before anything reads it | 必须先有新模型,才能进行读取 |
| F2 Gate on plans | Reads switch over while writes still dual-run | 读取切换,写入保持双重运行 |
| F3 Migrate orgs | Backfill — nobody left on the old model | 数据回填,确保旧模型无遗留 |
| F4 Remove machinery | Contract — safe only after F3 | 收缩,仅在 F3 完成后才安全 |
This is the expand/contract pattern applied to a domain model, not just a schema. Expand (F1) adds the new thing alongside the old. Migrate (F2–F3) moves reads then data. Contract (F4) deletes the old thing once nothing points at it. 这是将“扩展/收缩”模式应用于领域模型,而不仅仅是数据库架构。扩展(F1)在旧事物旁边添加新事物。迁移(F2–F3)先移动读取逻辑,再移动数据。收缩(F4)在没有任何引用指向旧事物后将其删除。
Gating on the plan: The gate collapses to a single question: does this org’s plan grant the feature? One entitlement resolver instead of two. 基于套餐的权限控制: 权限检查简化为一个单一问题:该组织的套餐是否授予了此功能?用一个权限解析器取代了两个。
final class EnsureFeatureAccess {
public function handle(Request $request, Closure $next, string $feature) {
$plan = $request->user()->organization->currentPlan();
abort_unless($plan?->grants($feature), 403, 'Upgrade required.');
return $next($request);
}
}
Two edge cases worth calling out. Metered features (email sends, certificate issuance) aren’t just on/off — the plan grants a credit balance, so the gate checks remaining quota, not just entitlement. And internal orgs (superadmin-owned) get the top tier granted permanently, so ops tooling never trips the paywall. 有两个边缘情况值得一提。计量功能(如邮件发送、证书签发)不仅仅是开关——套餐授予的是信用额度,因此权限检查不仅要看授权,还要检查剩余配额。此外,内部组织(超级管理员所有)被永久授予最高等级权限,以确保运维工具不会触发付费墙。
A note on the payment edge cases: Migrating billing surfaced the usual payment-gateway papercuts: an order reference that exceeded the gateway’s 30-char cap, a null payer phone that failed checkout for every paid org, and validation errors that were swallowed instead of surfaced. None are glamorous — but a billing migration is exactly when they bite, because every org is re-subscribing at once. 关于支付边缘情况的说明: 迁移计费系统暴露了支付网关常见的琐碎问题:订单引用超过了网关 30 字符的限制、付款人电话为空导致所有付费组织结账失败、以及被吞掉而非抛出的验证错误。这些问题虽然不起眼,但在计费迁移时却会集中爆发,因为所有组织都在同一时间重新订阅。
Testing the gate: The behaviour worth pinning: no plan → blocked; plan that grants it → allowed. 测试权限检查: 需要锁定的行为:无套餐 → 拦截;套餐包含该功能 → 允许。
it('blocks a feature the plan does not grant', function () {
$org = Organization::factory()->onPlan('starter')->create();
actingAs($org->owner)
->get('/reports/advanced')
->assertForbidden();
});
it('allows a feature the plan grants', function () {
$org = Organization::factory()->onPlan('business')->create();
actingAs($org->owner)
->get('/reports/advanced')
->assertOk();
});
Takeaway: When you’re changing how entitlements work, treat it like a schema migration even though it’s business logic. Expand, migrate reads, migrate data, then contract — one deployable phase at a time. The four-migration overhead is cheap next to a billing outage where nobody can access what they paid for. 总结: 当你改变权限工作方式时,即使它是业务逻辑,也要像对待数据库架构迁移一样对待它。扩展、迁移读取、迁移数据,然后收缩——每次只部署一个阶段。相比于导致用户无法访问已付费内容的计费中断,这四个迁移阶段的额外开销是非常划算的。