Skip to content
ALI HAITHAM·TECH·responds in <1h
  • Homestart here
  • Workcase studies, projects
  • Serviceswhat I will build for you
  • Writingessays, series
  • Traincourses, labs, cohorts
  • Aboutthe engineer behind this site
Sign inStart a project
ALI HAITHAM · TECH
  • Home↗
  • Work↗
  • Services↗
  • Writing↗
  • Train↗
  • About↗
Sign inStart a project
Online
ALI HAITHAM·TECH

Engineering studio. Damascus, GMT+3.

aliyosef.online

Studio

  • Work
  • Writing
  • Training
  • About
  • Contact me

Portal

  • Sign in
  • Open a ticket
  • Track project
  • My account

Resources

  • Docs
  • Status
  • Changelog
  • Brand kit
  • Privacy
  • Terms

Newsletter

Field notes and tech news. Weekly. No fluff.

Free. Unsubscribe anytime.

Find me elsewhere
© 2026 Ali Haitham Yosef. All rights reserved.Hand-built in React 19. No frameworks of frameworks.Last deployed · 2026-05-08All systems operational
  1. Home/
  2. Writing/
  3. Multi-tenant
Multi-tenant

Per-tenant feature flags without spaghetti

Three years of running flags. The structure that scaled. The structure that did not.

March 30, 2026·9 min·by Ali Yosef

Loading essay…

ShareXLinkedInRSS
Get the next essay in your inbox.New posts every Sunday morning. Engineering, in plain language. Free, unsubscribe anytime.Subscribe→
AY
Ali Haitham Yosef

Engineer and writer. Builder of multi-tenant platforms, sync engines, and quietly reliable systems. 600+ shipped projects since 2015.

SubscribeStart a project→

Read next

  • MT
    Series
    Apr 8, 2026·Multi-tenant

    Multi-tenant Laravel, part one: schema choices

    Series opener. Schema-per-tenant vs row-per-tenant, the migration path, the queries that broke.

    12 min
  • TD
    Mar 22, 2026·Multi-tenant

    Tenant data exports without breaking the DB

    GDPR-grade exports on shared infrastructure. The query plan, the throttle, and the receipt.

    8 min
  • AL
    Mar 14, 2026·Multi-tenant

    Audit logs that actually scale

    Append-only, partitioned, queryable. The schema we landed on after losing two production weeks.

    11 min
←Back to all essays

A feature flag is a single line of branching logic that someone needed once. By the time you have forty of them across a codebase serving a hundred tenants, "needed once" has compounded into something that nobody fully understands. This essay is about the structure that prevented that compounding for us, and the structure that did not.

What feature flags are actually for#

Three honest uses, in order of how often they are right:

  1. Gradual rollout. A new feature ships behind a flag, gets enabled for one tenant, then ten, then everyone. The flag is a kill switch if something goes wrong.
  2. Per-tier capabilities. Pro-plan tenants get bulk-export. Free- plan tenants do not. The flag is a permission, not a release valve.
  3. Tenant-specific behaviour. A particular customer needs a tweak the rest do not want. The flag carves out a corner.

Anything else, you probably do not need a flag. A/B testing is its own problem with its own tools. Long-running flags that nobody dares remove are the problem this essay is about.

The shape that scaled#

We landed on a single table:

CREATE TABLE feature_flags (
    id              BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
    key             VARCHAR(120) NOT NULL,           -- 'bulk_export'
    description     TEXT NOT NULL,                   -- the why
    owner_team      VARCHAR(60) NOT NULL,            -- who removes this
    rollout_kind    ENUM('global', 'plan', 'tenant', 'percent') NOT NULL,
    rollout_value   JSON NOT NULL,                   -- per-kind payload
    sunset_date     DATE NOT NULL,                   -- the death sentence
    created_at      TIMESTAMP NOT NULL,
    UNIQUE KEY (key)
);

Three details that earn their keep:

owner_team is required#

A flag without an owner is a flag nobody removes. The first version of this table left it nullable; we ended up with thirty flags and no clarity on who could turn any of them off. Making it NOT NULL and seeding "platform" as a default forced the conversation.

sunset_date is required#

Every flag, even permanent ones, has a date by which it must be revisited. "Revisited" can mean removed, can mean confirmed, can mean extended. The point is that the table is not write-only. We run a scheduled job that emails owners 14 days before sunset; if a flag sunsets without revision, it gets a STALE tag and shows up red on the platform dashboard.

rollout_kind and rollout_value are normalised#

Every flag is one of four shapes:

  • global — value: {"enabled": true}. Boolean for everyone.
  • plan — value: {"plans": ["pro", "enterprise"]}. Tier gate.
  • tenant — value: {"tenant_ids": [42, 113, 7]}. Explicit list.
  • percent — value: {"percent": 25}. Stable hash by tenant id.

By forcing every flag into one of four shapes, we got two things: a single evaluation function instead of forty, and the ability to list "all flags currently enabled for tenant 42" with one query.

The evaluation function#

namespace App\Features;

class Features {
    public function isOn(string $key, ?Tenant $tenant = null): bool {
        $flag = $this->cache->remember(
            "flag:{$key}",
            60,
            fn () => FeatureFlag::where('key', $key)->first()
        );
        if (!$flag) {
            return false;  // a flag we forgot about is off
        }

        $value = $flag->rollout_value;
        $tenant ??= app(TenantContext::class)->current();

        return match ($flag->rollout_kind) {
            'global' => (bool) ($value['enabled'] ?? false),
            'plan'   => $tenant && in_array($tenant->plan, $value['plans'] ?? [], true),
            'tenant' => $tenant && in_array($tenant->id, $value['tenant_ids'] ?? [], true),
            'percent' => $tenant
                && (crc32("{$key}:{$tenant->id}") % 100) < ((int) ($value['percent'] ?? 0)),
        };
    }
}

Three things to notice:

  1. Default off. A missing flag returns false. This sounds obvious until you realise the alternative is "default on so we don't accidentally hide a feature in dev." That is a release waiting to happen.
  2. Stable hash for percent rollouts. crc32("{key}:{tenant_id}") is deterministic per (flag, tenant). The same tenant gets the same answer every time, even after restarts. This is how you do gradual rollouts without flapping users in and out.
  3. 60-second cache. Flag changes propagate within a minute. If you need instantaneous propagation (you should not, almost ever), you need something else. Cache misses on every call cost more than the minute of staleness.
The check is the API

The application code only ever calls Features::isOn('bulk_export'). It does not know whether this is a global flag, a plan flag, or a percent rollout. Decoupling the check from the rollout shape is what lets you change a flag from "tenant 42 only" to "all pro plans" without touching application code.

The patterns that did not scale#

We tried two structures that broke at around twenty flags.

Per-feature env vars#

FEATURE_BULK_EXPORT=true, FEATURE_DARK_MODE=enterprise, etc. Worked for a while. Failed when we needed per-tenant gating: env vars cannot say "tenant 42 yes, tenant 113 no" without an array, and the array became unreadable. Also, every change required a deploy.

Per-tenant config blob#

A config_overrides JSON column on the tenants table. Worked at five flags. At fifteen, the column was a wall of {"feature_x": true, "feature_y": false, "feature_z": true, ...} for every tenant, with no central place to ask "what does feature_x mean" or "who owns it." The structure put the data on the tenant, but flags belong to features, not tenants.

A small accountability ritual#

Every Friday morning, a Slack bot posts the list of flags whose sunset date is in the next 30 days, with their owners. The owner has a week to either: confirm and extend, remove the flag (delete the table row plus the Features::isOn calls), or convert it to a permanent capability (add to the plan table).

The first month we did this, we removed nine flags. Three of them had been on for over a year with no objections. Half of those had been "experimental" features long since shipped to everyone.

What this bought us#

After eighteen months on the new structure, we had forty-six active flags. The dashboard fits on a screen. New flags take five minutes to add (insert + reference). Old flags get retired on schedule. No engineer has needed to ask "what does feature X mean" in over a year.

The ritual is more important than the table schema. The schema is just the surface that makes the ritual possible.

The next part of the multi-tenant series is on tenant data exports without breaking the database. Series page tracks the rest.