DEV Community

Cover image for Killing à-la-carte: migrating a feature-gating model to plans-only
Nasrul Hazim
Nasrul Hazim

Posted on

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.

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
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.

Top comments (0)