# Feature Flags for Multi-Tenant SaaS: Tenant Rollouts

> Source: <https://dev.to/muhammadalimughal/feature-flags-for-multi-tenant-saas-tenant-rollouts-24c9>
> Published: 2026-11-03 12:49:30+00:00

In a B2B SaaS product, the user is the wrong unit for a feature flag. Your customer is the tenant: one company, one contract, one admin who files the ticket when the invoice screen changes under 40 of their staff mid-month. If your flag system buckets by user ID, half of that company sees the new screen and half sees the old one, and your support team spends a week explaining a bug that is really a rollout design decision.

I run [Loop Origin](https://looporigin.com/?utm_source=devto&utm_medium=article&utm_campaign=feature-flags-multi-tenant-saas-staged-rollouts), where we build and run multi-tenant platforms, and staged rollouts are one of the first things I put in place before a product's tenant count grows. I wrote this with help from AI tools and checked every command and claim myself. The code and outputs below ran on my machine today.

**Direct answer:** Evaluate flags per tenant, not per user, in a fixed order: kill switch, explicit tenant override, plan entitlement, then a deterministic percentage ring. Hash the flag key with the tenant ID so cohorts are sticky as you ramp. Cache evaluations briefly, audit every change, and give each flag an owner and an expiry date.

Most flag messes start because three different jobs share one boolean:

| Flag type | Question it answers | Lifetime | Who changes it | Example | 
|---|---|---|---|---|
| Release flag | Is this unfinished or risky code path on for this tenant? | Days to weeks, then deleted | Engineering | `new-invoice-engine` | 
| Entitlement | Has this tenant paid for this capability? | Permanent, part of billing | Product and billing | `sso-saml` on the Enterprise plan | 
| Kill switch | Can we turn this off for everyone in seconds? | Permanent for risky integrations | On-call engineer | `disable-bulk-export` | 
| Ops / config flag | How should this behave right now? | Long-lived | Engineering | Batch size, rate limit, provider choice | 

Keep entitlements in your billing domain, not your flag tool. A release flag that quietly becomes "the Enterprise feature" turns into a revenue bug the day someone deletes it.

Order matters because it decides who wins when rules conflict:

``` php
1. Kill switch on?                 -> OFF for everyone, stop
2. Explicit tenant override?       -> use it (design partners, a tenant who asked to wait)
3. Plan entitlement required?      -> OFF if the tenant's plan lacks it
4. Percentage ring                 -> ON if bucket(flag, tenant) < ring%
5. Default                         -> OFF
```

A minimal Postgres shape that supports this:

```
CREATE TABLE feature_flags (
  key            text PRIMARY KEY,
  kind           text NOT NULL CHECK (kind IN ('release','kill','ops')),
  rollout_pct    numeric(5,2) NOT NULL DEFAULT 0,  -- 0.00 to 100.00
  required_plan  text,                             -- NULL = any plan
  killed         boolean NOT NULL DEFAULT false,
  owner          text NOT NULL,
  expires_on     date,                             -- required for kind='release'
  updated_at     timestamptz NOT NULL DEFAULT now()
);

CREATE TABLE feature_flag_overrides (
  flag_key   text REFERENCES feature_flags(key) ON DELETE CASCADE,
  tenant_id  bigint NOT NULL,
  enabled    boolean NOT NULL,
  reason     text NOT NULL,
  PRIMARY KEY (flag_key, tenant_id)
);
```

The `owner` and `expires_on` columns are not decoration. They are what lets you find and delete stale release flags later.

Never use `random()` for a rollout. A tenant must get the same answer on every request, on every server, and must stay in the cohort as you ramp from 10% to 25%. Hashing `flag:tenant` gives you all three:

``` js
import { createHash } from 'node:crypto';

export function bucket(flag, tenantId) {
  const h = createHash('sha256').update(`${flag}:${tenantId}`).digest();
  return h.readUInt32BE(0) % 10000;          // 0..9999, i.e. 0.01% steps
}
export const enabled = (flag, tenantId, pct) => bucket(flag, tenantId) < pct * 100;
```

I ran it over 10,000 synthetic tenants:

```
10% cohort size: 995
25% cohort size: 2458
50% cohort size: 4923
10% tenants still on at 25%: true
25% tenants still on at 50%: true
overlap between two 10% flags: 98 (9.8% of cohort)
```

The cohorts land close to target, every tenant already on stays on as you ramp, and two different flags at 10% overlap at about 10%, which is what independent cohorts should do. Salting with the flag key matters: if you hash only the tenant ID, the same unlucky tenants are first in line for every risky release.

The cost is small. One million evaluations took 1,350 ms in a single Node 24 process on this box, about 1.35 µs each. That is cheap enough to evaluate per request, so caching is about reading flag definitions, not the hash.

| Ring | Who | Typical size | Exit criteria before the next ring | 
|---|---|---|---|
| 0 | Your own internal tenant | 1 | Core flows pass, no new error types | 
| 1 | Design partners who opted in | 3 to 10 | Explicit feedback, support tickets triaged | 
| 2 | Percentage of self-serve tenants | 10% | Error rate and key metrics within your agreed threshold for several days | 
| 3 | Wider self-serve | 50% | Same, plus performance under real load | 
| 4 | Everyone, enterprise last | 100% | Enterprise admins notified per contract terms | 
| Cleanup | Delete the flag and the old path | n/a | Old code removed in one PR | 

Enterprise tenants go last on purpose. They are most likely to have change-notice clauses, trained staff and internal documentation that a silent UI change breaks.

| Option | What you run | Pricing model | Best fit | Watch out for | 
|---|---|---|---|---|
| Hand-built (tables above + cache) | Postgres, Redis | Your engineering time | Few flags, one codebase | No UI or audit log unless you build them | 
| Laravel Pennant | Your Laravel app and database | Free, open source | Laravel monoliths | Resolved values are stored per scope, so changing a rule needs a purge | 
| Self-hosted open source (Unleash, Flagsmith, GrowthBook) | A flag service and its database | Free core, paid tiers | Several services, need a UI | Another service to secure and upgrade | 
| Hosted flag platform | Nothing | Subscription, often tied to seats or usage | Many teams, experimentation | Vendor cost and a runtime dependency | 

In Laravel, Pennant makes the tenant the scope directly:

``` php
use Laravel\Pennant\Feature;

Feature::define('new-invoice-engine', function (Team $team) {
    if ($team->isInternal()) return true;
    return crc32("new-invoice-engine:{$team->id}") % 100 < 10;  // 10% ring
});

if (Feature::for($team)->active('new-invoice-engine')) { /* new path */ }
```

With Pennant's database driver, the first resolved value is stored and reused. That makes it sticky, but raising 10% to 25% in code does not move tenants already stored as off until you run `php artisan pennant:purge new-invoice-engine`. Plan that step into the ramp.

`expires_on` keeps the count honest.
If you are planning a multi-tenant product and want rollouts designed in from day one, this is the kind of [custom web application work](https://looporigin.com/services/custom-web-application-development?utm_source=devto&utm_medium=article&utm_campaign=feature-flags-multi-tenant-saas-staged-rollouts) my team does.

Tenants, for anything that changes shared workflows, data or UI. Per-user targeting splits a single customer into two experiences. Keep user-level flags for personal preferences and internal staff previews.

Hash the flag key and tenant ID into a fixed bucket and compare it with the percentage. The same tenant always lands in the same bucket, so raising the percentage only adds tenants.

No. Entitlements are permanent billing rules. Release flags are temporary and should be deleted. Mixing them makes it risky to clean up flags.

Short enough that a kill switch takes effect within your incident target, often 10 to 30 seconds, or use push invalidation so changes apply almost immediately.

Once it has been at 100% long enough to be confident, usually within one sprint. Remove both the flag and the old code path in the same pull request.

Not at the start. A table, a cache and an audit log cover a single product. Move to a dedicated service when several teams or services need a UI, experiments or approvals.
