Enforcing Plan Limits Without Littering The Whole Codebase
Enforcing Plan Limits Without Littering The Whole Codebase

Every SaaS product eventually has to answer the same question in fifty different places: is this organisation allowed to do this? Five users on the free plan. Screenshots from Starter upwards. Approvals not on Free. A retention window that differs per plan.
Answered badly, that question spreads through the codebase as a rash of if statements, each slightly different, each one a place where a paid feature can leak. Answered well, it is one resolver, two kinds of check, and a handful of tests.
This is how plan enforcement works in Happy Tracker, including the bug that gave every licence-less free organisation a paid image quality setting for months without anyone noticing.

Where a limit actually lives
A limit is not one value. It is up to three values resolved in a fixed order, and the confusion in most implementations comes from pretending otherwise.
- The plan row. A JSON config column holding the commercial facts:
max_users,screenshots,approvals,image_quality,retention_days. It changes when the pricing changes, which is rarely and deliberately. - The organisation. A preference — whether they want a feature they are entitled to. The owner changes it whenever they like. Its absence always means the default, never means “allowed”.
- The member. A per-person exception, used sparingly. Contractors who are monitored when employees are not; one manager who may clock in from a browser when nobody else may.
The plan config lives as JSON rather than as columns because plans gain settings constantly and each new one would otherwise be a migration plus a backfill plus a deploy. The cost is that nothing validates the shape, so there is one test that asserts every plan row contains every expected key. That test has caught two typos in production config, which is two more than the schema would have.
The critical part is the order, and that it is written exactly once:
class PlanResolver
{
public function limit(Organization $org, string $key): mixed
{
return $this->planFor($org)->config[$key] ?? $this->default($key);
}
public function planFor(Organization $org): Plan
{
$licence = $org->activeLicence(); // may legitimately be null
if ($licence) {
return $licence->plan;
}
// No licence is not a missing answer. It IS the answer: Free.
return Plan::where('slug', 'free')->firstOrFail();
}
}
Four lines of that method are the subject of the next section, because they were wrong for a long time.
The bug: an organisation with no licence row
Happy Tracker’s free plan allows five users with no time limit and no card. An organisation on the free plan has no licence row at all — there is nothing to licence, so nothing was created. That is a reasonable data model and it quietly broke a helper.
The original code did something like this:
// the old, wrong version
public function planFeatureOrFree(Organization $org, string $key): mixed
{
$licence = $org->activeLicence();
$plan = $licence?->plan ?? Plan::first(); // <-- here
return $plan->config[$key] ?? null;
}
Plan::first(). No ordering, so in practice the lowest id in the plans table, which was not the Free plan — it was whichever plan had been seeded first during development. Free organisations were reading a paid plan’s config.

The visible symptom was that free organisations were getting image_quality: medium, a Starter setting, so their screenshots were larger and better than the plan they were on. Storage we were paying for, a feature we were selling, given away by a fallback nobody had thought about.
What made it survive for months is the thing worth taking from this story: nobody reports a bug that benefits them. A limit that is too strict generates a ticket within the hour. A limit that is too generous generates nothing at all, ever. The only way that class of bug is found is by reading the code or by reconciling usage against entitlements, and the second one only happens when the bill arrives.
Every
??in a permission path is a decision about what happens when the data is absent. Write the decision down deliberately, or the language will make it for you — and it will make it silently.
The fix was to look the Free plan up by slug and to fail loudly if it is missing. If someone deletes the Free plan row, the correct behaviour is a 500 in staging, not a silent upgrade for every free customer in production.
Check at the point of creation, never in the interface
Hiding the “Add user” button when an organisation is at its cap is good design and it is not enforcement. The API is the product. Anything the interface can do, a script can do, and in a multi-tenant application the interface and the API are used by the same people with the same token.
The rule is that the check lives as close to the write as you can get it. For a count limit, that means immediately before the insert, in a transaction, with the count taken inside the same transaction.
public function store(StoreMemberRequest $request)
{
$org = $request->user()->organization;
return DB::transaction(function () use ($org, $request) {
$max = app(PlanResolver::class)->limit($org, 'max_users');
$count = $org->members()->lockForUpdate()->count();
if ($count >= $max) {
throw ValidationException::withMessages([
'email' => "Your plan includes {$max} users and you have {$count}. "
. "Remove a member or upgrade to add more.",
]);
}
return $org->members()->create($request->validated());
});
}
The lockForUpdate is not paranoia. Two invitations accepted in the same second will both read a count of four against a cap of five and both insert, and you now have six users on a five-user plan. It happens rarely and it happens for real, and when it does the organisation is over its cap in a way no interface can explain.
The alternative, if your database supports it cleanly, is a counter column with a check constraint. Either is fine. What is not fine is a bare count with no lock, which is the version almost everyone writes first.
The message is part of the feature
A 422 that says “limit exceeded” is a failure of product design wearing a status code. A user who hits a limit has one question — what do I do now — and the response either answers it or generates a support email.
- Name the number. “Your plan includes 5 users”, not “user limit reached”. People need to know what they have before they can decide anything.
- Name the current state. “and you have 5”. This catches the common confusion where a deactivated member is still occupying a seat.
- Give two routes out. Remove somebody, or upgrade. One of those is free and you should say it first; the customer knows perfectly well that you would prefer the other one.
- Use the right status. 422 for a count you could resolve yourself, 403 for a feature that is not on your plan. They are different situations and clients want to handle them differently.
Counts and features are not the same thing
Once you separate the two kinds of limit, most of the sprawl in a permissions layer disappears, because each kind has exactly one place it belongs.

A count limit is a number of rows: users, projects, attachments, comments per task. It is checked immediately before an insert, it fails with 422, and it never affects reading. An organisation that drops from Starter to Free with twelve projects keeps all twelve projects, and simply cannot create a thirteenth.
A feature limit is a capability: approvals, screenshots, the advanced sprint report, the export. It is checked once at the door, in middleware or a policy, and it fails with 403.
class RequiresPlanFeature
{
public function handle(Request $request, Closure $next, string $feature)
{
$org = $request->user()->organization;
if (! app(PlanResolver::class)->limit($org, $feature)) {
return response()->json([
'error' => 'plan_feature_required',
'feature' => $feature,
'message' => 'This feature is available from the Starter plan.',
], 403);
}
return $next($request);
}
}
// routes/api.php
Route::middleware('plan:approvals')->group(function () {
Route::post('timesheets/{timesheet}/approve', [ApprovalController::class, 'store']);
});
One middleware, one parameter, applied on a route group. The controller contains no plan logic at all, which is the entire point: a developer adding a new approval endpoint six months from now puts it in the existing group and gets the enforcement for free, without knowing that any of this exists.
A stable error string matters more than it looks. The desktop trackers and the Vue SPA both switch on it to decide whether to show an upgrade prompt or an error toast, and matching on a human-readable message means every copy edit is a client-side bug.
Caching the lookup without serving a stale plan
Once every guarded action resolves a plan, that resolution happens a great many times per request. A page listing forty members with a per-member capability badge will resolve the same organisation’s plan forty times, and each one is a query for the licence and a query for the plan.
The fix is a per-request memo rather than a shared cache, and the distinction matters. A request-scoped array is emptied at the end of the request, so an upgrade that happens at 11:04 is in force at 11:04. A Redis entry with a fifteen-minute time to live means a customer who has just paid you money spends up to fifteen minutes still being refused the feature they bought, which produces the worst support conversation in the product.
class PlanResolver
{
private array $memo = [];
public function planFor(Organization $org): Plan
{
return $this->memo[$org->id] ??= $this->resolve($org);
}
}
// registered as a singleton, so the memo lives for one request
$this->app->singleton(PlanResolver::class);
If you genuinely need a cross-request cache because your plan table is under load, key it on the organisation and clear it in an observer on the licence model. Never let it expire on time alone. A plan change is an event you know about at the moment it happens, so there is no reason to discover it by waiting.
This is the same reasoning behind how organisation scoping works everywhere else in the application, which we went through in one database, many companies: resolve once per request, from one place, and invalidate on the event rather than on a timer.
Trials, expiry and the awkward middle
A licence has a start date and an end date, and the interesting question is what an organisation is one minute after the end date.
There are three tempting answers and only one of them is kind. Locking the account entirely punishes a customer for a failed card. Leaving the plan in force indefinitely means the enforcement does not exist. What we do is fall back to Free — the same path as an organisation that never had a licence at all — which means tracking keeps working for up to five people, all historic data stays visible, exports still run, and the paid features stop.
That falls out of the resolver for nothing, because activeLicence() already filters on the date range. An expired licence is not a licence, so the method returns null, so the organisation reads Free. One condition, and the entire expiry behaviour is defined.
- Warn before the date, not after it. Fourteen days, seven days, one day. An expiry that arrives unannounced reads as a trap even when the dates were on the invoice.
- Show the state in the interface permanently. A small badge saying which plan is in force, on every page, for owners. Most “why can I not do this” tickets are answered by that badge.
- Never let an expiry run mid-session. A timer that stops recording at midnight because a licence lapsed loses somebody’s evening. Let the current day finish.
Happy Tracker does not auto-renew, which removes an entire category of this problem along with the support load that comes with it. The trade is that expiry happens more often and therefore has to be handled gracefully rather than as an edge case.
What happens to existing data on a downgrade
This is where a plan system stops being a technical question and becomes a question about whether customers trust you.
The rule is absolute: a downgrade never deletes anything. Not projects over the new cap, not screenshots taken under the old plan, not approved timesheets from a period when approvals were included. A customer who pays less this month does not lose the work they did last month.
- Counts over the cap are frozen, not trimmed. Twelve projects on a five-project plan means no new projects until they are under five. Every existing project stays readable, editable and exportable.
- Feature data becomes read-only, not invisible. If screenshots are no longer included, capture stops and the existing images remain in the interface until retention removes them on its normal schedule.
- Export always works. On every plan, including Free, including an expired one. Holding somebody’s data hostage behind a payment is the single fastest way to make sure they never come back.
- Say what will happen before it happens. The downgrade screen lists the specific consequences with real numbers: “you have 12 projects, this plan includes 5, you will not be able to create new ones.”
The engineering consequence of that rule is that every count check is a check on creation and never a check on existence. Once you have written it that way, a downgrade needs no migration, no cleanup job and no dangerous script. It is a row change on a licence and nothing else moves.
An automated process that deletes customer data because a payment changed will eventually delete the wrong customer’s data. The safest version of that job is the one that was never written.
The tests that stop a leak
Four tests per limit, and they are the same four every time. They are boring to write and they are the only thing standing between a config change and a refund request.

public function test_free_org_without_a_licence_reads_free_config(): void
{
$org = Organization::factory()->create(); // deliberately no licence
$plan = app(PlanResolver::class)->planFor($org);
$this->assertSame('free', $plan->slug);
$this->assertSame('low', $plan->config['image_quality']);
}
public function test_creating_one_over_the_cap_fails_and_changes_nothing(): void
{
$org = $this->freeOrgWithMembers(5); // cap is 5
$this->actingAs($org->owner)
->postJson('/api/members', ['email' => 'six@example.com'])
->assertStatus(422);
$this->assertSame(5, $org->members()->count());
}
The first test is the one nobody writes, because the natural instinct is to set up an organisation with a plan and test the plan. Testing the absence of a licence is what would have caught the bug in this article on the day it shipped.
The other two are equally mechanical: a gated route returns 403 for a Free organisation and 200 for a Starter one, and after a downgrade the existing rows are still readable and the export still succeeds. Write them as a shared trait and every new limit costs four lines rather than four tests.
The super-admin view that catches the rest
Automated tests cover the limits you thought of. For the ones you did not, there is a single page in the super-admin area that lists every organisation with its plan, its user count, its project count and its storage, with anything over its entitlement highlighted.
That page has found three problems that no test would have: an organisation on Free with nine users from a migration that predated the cap, a customer whose licence had expired without the plan reverting, and a trial that never ended. All three were silent, all three were costing money, and all three were visible in one glance at a table. If you have plan limits at all, build that page before you build the third limit.
On Monday morning
- Grep for your plan config access. Every place that reads a plan config key directly rather than through one resolver is a place the answer can differ. Count them. If it is more than one, that is the refactor.
- Find every fallback in that path. Each
??, each?:, eachfirst()without an order. Ask what it returns when the data is missing and whether that is the answer you would choose deliberately. - Create one organisation with no licence row in a console and print its resolved config. If it does not say Free, you have this bug today.
- Check one count limit for a race. Two simultaneous creates against a cap. If there is no lock, add one before somebody finds it.
- Write the downgrade sentence down. Exactly what a customer loses and keeps. If nobody in the company can state it without looking, your customers certainly cannot, and they are the ones deciding whether to renew.
None of this is complicated code. It is one resolver, one middleware, a transaction around a count, and a refusal to delete anything. The complexity in plan enforcement never comes from the enforcement — it comes from the same question being answered in forty places by forty slightly different lines, and from nobody having decided what the answer should be when the data is simply not there.

