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.
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.
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 into the prerequisite chain New model must exist before anything reads it F2 Gate features on the plan, not the feature-sub Reads switch over while writes still dual-run F3 Deploy op migrates existing orgs onto a plan Backfill — nobody left on the old model F4 Remove the à-la-carte machinery Contract — safe only after F3This 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 seed F2 gate on plan F3 backfill orgs F4 drop features
┌────────┐ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ plans │ --> │ reads: plan │ -> │ every org on │ -> │ delete a-la- │
│ exist │ │ writes: both │ │ a real plan │ │ carte code │
└────────┘ └──────────────┘ └──────────────┘ └──────────────┘
safe safe safe safe
Enter fullscreen mode Exit fullscreen mode
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);
}
}
Enter fullscreen mode Exit fullscreen mode
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.
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();
});
Enter fullscreen mode Exit fullscreen mode
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.
답글 남기기