SaaS Feature Gating: Entitlements, Plan Limits, and the Architecture That Scales
Every SaaS codebase we audit contains some version of this line:
if ($user->getPlan() === 'pro') {
// show the feature
}
It works on day one. Eighteen months later the same check exists in forty places, three of them disagree with each other, and marketing wants to move a feature from Pro to Business without breaking the twelve enterprise customers who negotiated custom terms. At that point SaaS feature gating stops being a conditional and becomes an architecture problem. This post lays out the architecture that holds up: an entitlement service, a data model for plan limits, enforcement patterns for a Symfony backend and a React frontend, and the operational work nobody budgets for, like plan migrations and per-customer overrides.
Why SaaS feature gating rots
The if plan == 'pro' pattern couples two questions that change at different speeds: what a user is allowed to do, and what they pay for. Product capabilities change when engineering ships. Pricing changes when sales and marketing decide it should. When both live in the same conditional, every pricing experiment becomes a code change, and every code change risks breaking billing.
The failure modes are predictable. A feature check written against the plan name breaks the moment you rename or split the plan. Grandfathered customers need the old behavior, so someone adds || $user->getPlan() === 'pro_legacy_2024' and the conditional grows a beard. Sales closes an enterprise deal with a custom seat limit, and since there is no place to store "this customer gets 500 seats on the Business plan," the workaround lands in a config file, or worse, a hardcoded customer ID.
None of this is a skill issue. It is what happens when the codebase has no concept between "plan" and "feature."
The entitlement service: decouple ability from billing
The fix is a layer in the middle. A plan is a commercial artifact: a name, a price, a billing interval. An entitlement is a capability: api_access, sso, audit_log, seats: 25, projects: 100. The entitlement service owns exactly one mapping: plan to entitlements. Application code never asks "what plan is this account on?" It asks "does this account have sso?" or "how many seats does this account get?"
That single indirection buys you a lot:
- Pricing can change without touching feature code. Moving a feature between plans is a data change.
- Grandfathering becomes a versioned plan row instead of a conditional.
pro_2024andpro_2026map to different entitlement sets, and no application code knows or cares. - Enterprise overrides get a home. An account-level override table beats a hardcoded customer ID in every way that matters during an incident.
Where you check entitlements matters as much as where you store them. Check at the API boundary, because the API is the real contract and anything a client can call, a script can call. Check again in UI rendering, but treat that as presentation, not security. A hidden button is a courtesy; a rejected request is a guarantee.
The data model for plan limits
Boolean entitlements are the easy half. Limits are where the model earns its keep, because "how many" comes in three flavors that behave differently:
Resource quotas are countable things: seats, projects, API calls per month. They need a current-usage counter next to the limit, and that counter is the part teams get wrong. Counting rows at request time works until the table gets large. A materialized usage counter updated on create and delete is boring and correct.
Soft limits allow the customer to exceed the number and pay for the difference. Usage-based API pricing is the classic case. The enforcement point does not block; it records. Your metering table becomes billing input, which means it needs to be append-only and auditable, because customers will dispute invoices and "we recomputed it from the events" is the only answer that ends the conversation.
Hard limits block, and blocking needs to degrade gracefully. Hitting a seat limit should never lock the whole workspace. Existing seats keep working, new invites fail with a clear message and an upgrade path. The 101st project is refused; the first 100 are untouched.
A workable schema is smaller than most teams expect:
plans (id, code, name, version)
entitlements (id, plan_id, feature_key, limit_value, limit_type)
-- limit_type: boolean | quota_soft | quota_hard
account_overrides(id, account_id, feature_key, limit_value, limit_type,
reason, created_by, expires_at)
usage_counters (account_id, feature_key, period, current_value)
Resolution order: override, then plan entitlement, then deny by default. The reason and created_by columns on overrides are not decoration. When someone asks in a year why account 4711 has 500 seats, the row should answer.
Enforcing entitlements in Symfony without polluting business logic
The temptation in a Symfony backend is to inject the entitlement checker into every service and sprinkle guards around. Resist it. Business logic should read as business logic.
Two patterns keep enforcement at the edge. For boolean entitlements, a PHP attribute on the controller action plus an event listener on kernel.controller_arguments does the job:
#[RequiresEntitlement('audit_log')]
public function exportAuditLog(Request $request): Response
The listener resolves the account from the request context, asks the entitlement service, and throws a 403 with a machine-readable error code (entitlement_missing:audit_log) that the frontend can turn into an upgrade prompt instead of a dead end.
Quota checks need arguments, so an attribute is a poor fit. Put them in a dedicated guard invoked at the start of the use case:
$this->quotaGuard->assertCanCreate($account, 'projects');
One line at the top of the handler, and the actual project-creation logic stays clean. The guard also owns the metering write for soft limits, so recording and enforcement cannot drift apart.
Cache the resolved entitlement set per account with a short TTL and invalidate on plan change and override change. Entitlements are read on every request; the plan table is not.
If your current codebase already has plan conditionals spread everywhere, this refactoring is well suited to an incremental strangler approach. It is the kind of structural work we do in legacy code optimization engagements: introduce the service, route new checks through it, and migrate old call sites in batches with test coverage proving parity.
The React hook pattern that avoids flicker
Frontend gating has one dominant failure mode: the flash. The app renders, entitlements load a beat later, and Pro features pop in or out in front of the user. It looks broken because it is. The client rendered before it knew the answer.
The fix is to make entitlements part of the session payload, not a separate fetch. Whatever endpoint or server component delivers the authenticated user should deliver the resolved entitlement set in the same response. Hydrate it into a context provider once, then a hook makes checks trivial:
const { has, limit } = useEntitlements();
if (!has('audit_log')) return <UpgradePrompt feature="audit_log" />;
Since the data arrives with the session, there is no loading state and nothing to flicker. Components either render the feature or render the upsell, deterministically, on first paint. In a Next.js app you can resolve entitlements server side and pass them through the layout, which means even the initial HTML is correct.
Keep the frontend honest about what it is: a mirror of the backend decision, not the decision itself. The 403 with entitlement_missing remains the enforcement. If the two ever disagree, the backend wins and the frontend has a bug.
Plan migrations and per-customer overrides
The operational half of feature gating is where most write-ups stop and most pain lives.
Plan migrations first. When pricing changes, never mutate the existing plan row. Create a new versioned plan (pro_2026), point new signups at it, and leave existing accounts where they are. Migrating existing accounts is then an explicit batch job with three properties: it is reversible, it logs the before and after state per account, and it runs the entitlement diff before touching anything. If an account would lose a capability it actively uses, that account goes on an exception list for a human decision, not into the batch. Silent downgrades of paying customers generate the angriest tickets you will ever read.
Overrides second. Enterprise deals will always need custom limits, so build the admin tooling before sales needs it at quarter end. The minimum viable version is a table view per account showing effective entitlements with their source (plan or override), a form to add an override with a mandatory reason field, and an optional expiry for trial-style exceptions. Half a day of Symfony admin work, and it permanently removes the incentive to hack customer exceptions into code.
One warning from experience: overrides accumulate. Review them quarterly. An override that exists on 30 percent of accounts has quietly become your real plan, and the pricing page is lying about it.
Where to start
If you are building the billing layer of a new product, put the entitlement service in from the start; it is a week of work at the beginning and a quarter of work once forty call sites exist. That layer is a standard part of how we approach custom software development for SaaS products. If you already have the forty call sites and a pricing change on the roadmap, an audit of the existing gating logic tells you how deep the coupling goes before you commit to a timeline. That is a typical scope for our code quality consulting.
Questions about your own entitlement setup, or a pricing migration you would rather not do twice? Write to hello@wolf-tech.io or have a look around wolf-tech.io. We have untangled this exact knot more than once.

