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. 分阶段计划: 我将其拆分为四个有序的迁移步骤。每个阶段都可以独立部署,并确保应用正常运行。

PhaseWhat it doesWhy this order
F1 Seed PlansNew model must exist before anything reads it必须先有新模型,才能进行读取
F2 Gate on plansReads switch over while writes still dual-run读取切换,写入保持双重运行
F3 Migrate orgsBackfill — nobody left on the old model数据回填,确保旧模型无遗留
F4 Remove machineryContract — 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. 总结: 当你改变权限工作方式时,即使它是业务逻辑,也要像对待数据库架构迁移一样对待它。扩展、迁移读取、迁移数据,然后收缩——每次只部署一个阶段。相比于导致用户无法访问已付费内容的计费中断,这四个迁移阶段的额外开销是非常划算的。