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 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 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
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.
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.
Top comments (0)