I shipped a feature to production and nobody could see it. On purpose.
The short version: I made a new UI the default for one account and left every other account on the old one, from the same deploy, on the same server, running the same code. The difference was one row in a database table. That row is a feature flag, and it lets you decouple deploy from release: shipping code and releasing it to a user become two separate decisions instead of one scary event. This is how I built the smallest version of that on a live SaaS I run as fractional CTO, and the design calls that made it worth the one table it costs.
Last month I made a rebuilt front-end the default for my own account on a product I maintain, and left everyone else on the classic one. Same deploy. Same code on the same server. The only difference was one row in a database table. That row is a feature flag, and getting to the point where a single row decides who sees what is one of those boring infrastructure investments that quietly changes how you ship.
Here is the story of how I got there, and the design decisions that matter if you are building something similar.
The thing I was replacing
Before this, the “who gets the beta” logic lived in an environment variable: a comma-separated list of account IDs in .env. Want to add someone? Edit .env, reload config, done.
It worked, and for a while it was fine. But look at what it cannot do:
- Change a cohort without a deploy. Every cohort change is an
.envedit, which on most setups means a deploy, or at least a config reload over SSH. - Tell you who changed it, and when. An env var has no audit trail. Six months later, “why is this account on the beta?” has no answer.
- Ramp gradually. It is an explicit list. There is no “give it to 10% of accounts and watch.”
- Roll back instantly. If the beta breaks for someone, fixing it means another
.envedit and another deploy, exactly when you least want to be deploying.
The enterprise version of this, LaunchDarkly, Unleash, Flagsmith, solves all of that by putting flags in data, changeable at runtime, observable. But bringing in a whole platform for one flag is over-engineering. So I built the smallest thing that has the right shape.
The smallest thing with the right shape
One table:
CREATE TABLE feature_flags (
id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
account_id INT UNSIGNED NOT NULL, -- 0 means "global default"
flag_key VARCHAR(80) NOT NULL, -- e.g. 'builder_v2'
is_enabled TINYINT(1) NOT NULL DEFAULT 0,
updated_by INT UNSIGNED NULL, -- who flipped it
created_at, updated_at,
UNIQUE (account_id, flag_key)
);
The interesting decision is the account_id = 0 sentinel. A row for a specific account applies to that account. A row with account_id = 0 is the global default for the flag. Resolution is simple: an account-specific row wins, otherwise the global row decides, otherwise the flag is off.
That one sentinel gives you the entire rollout lifecycle for free:
- Canary. A handful of explicit account rows, ON. Only they see it.
- General availability. Flip the global row ON. Now everyone, new accounts included, gets it, because any account without its own row falls through to global.
- Cleanup. Delete the now-redundant explicit ON rows. They are covered by the global row anyway.
New accounts are the part people miss. With an explicit allowlist, a new signup is off until someone adds them. With a global default row, a new signup inherits the current default automatically. The table answers “what about new users?” without any extra code.
The design decision I am most glad I got right
I resisted the urge to write a BuilderBetaService that owned all of this. I did write one, because it already existed from the env-var days. But I made it a paper-thin wrapper over a generic service:
class BuilderBetaService {
public function isEnabledFor(int $userId): bool {
if (config('app.env') === 'development') return true; // always-on locally
return $this->featureFlags->isEnabledFor($userId, 'builder_v2');
}
}
The temptation, when the second feature needs flagging, is to write PaymentsBetaService, AnalyticsBetaService, and so on. Do not. Ten features would mean ten boilerplate classes that all do the same thing with a different string. The generic FeatureFlagService::isEnabledFor($userId, $flagKey) is the whole API. A new flag is a new string, not a new class. The wrapper should eventually be deleted, not multiplied.
This is the same instinct as any good abstraction: the thing that varies, the flag key, is a parameter, not a new type.
Where the decision lives matters more than you would think
The flag gets consulted in a few very different places, and each one taught me something about where a flag decision belongs:
- In a route guard. “Can this account open the v2 URL?” This is genuinely a middleware’s job. It is route access control, and it should not be hand-rolled as an
ifin every controller. - In a data transformer. “Which URL do I put on this Edit button, the v2 one or the classic one?” This is not a middleware’s job. It is shaping a response. It needs a service call.
- On the frontend. “Do I render the new UI?” This one is the trap. The tempting move is to leak the decision into individual API responses, a
builderV2boolean on each tile, a v2 URL baked into each record. That works, but it is N leaks for N features, and the frontend and backend drift apart. The right shape is to send the account’s enabled flags once at bootstrap as a list, and let any component askfeatures.has('builder_v2')locally. One source of truth, no drift.
The general lesson: a feature flag is not one kind of check. It is a route gate, a data-shaping input, and a UI toggle, and each wants a different mechanism. Forcing all three through the same tool, usually middleware, because that is the one people reach for, is how you end up fighting the framework.
The class of bug this design surfaces
Making a rebuilt UI the default surfaces a specific kind of bug, and it is worth naming because the fix is a lesson in itself. The new surface can be technically correct on its own while quietly disagreeing with the rest of the app about shared state.
The concrete version I hit: every other page in the app set the active record in a shared front-end store and then routed to itself through a common helper. The new surface reached itself through a full server-side page reload instead, and never touched the store. So it painted the right thing (it read that from the URL) while a shared header elsewhere on the screen read the stale store. Correct in isolation, wrong in context.
The fix was not to invent new syncing logic. It was to make the new surface navigate the same way every other page already did. The mechanism was right there. The new thing just was not using it.
That is the feature-flag lesson in miniature. The win is usually not a clever new abstraction. It is making the new thing use the pattern the rest of the system already trusts.
What I deliberately did not build yet
Two things I designed for but did not build:
- Percentage rollout. A
rollout_percentcolumn and a deterministic hash,hash(account_id + flag_key) % 100 < percent, so you can ramp 5% to 20% to 100% without flicker. Deterministic means an account’s bucket never changes, so it is never on-today-off-tomorrow. I do not need it for a canary of one account. I will need it at thousands. The resolution logic leaves a clean slot for it. - A flag lifecycle registry. Here is the trap: a rollout flag is temporary. At GA you are supposed to delete the flag, the rows, and the old code path. Skip that and a year later you have forty zombie flags, two live code paths per feature, and nobody brave enough to delete any of it. The fix is to track each flag’s owner and expected removal date, and to distinguish temporary rollout flags from permanent kill switches (like
maintenance_mode) so cleanup never nukes a switch you actually need.
Both are future work. But I wrote the current version so that adding them is additive, not a rewrite. That is the real test of the abstraction: not what it does today, but whether tomorrow’s version fits without tearing up today’s.
The part that actually changed how I work
The deploy that made the new UI default touched production for everyone. But the release, the moment any real user’s experience changes, is now a database write I control, watch, and can undo in one command. Deploy and release used to be the same scary event. Now they are two separate, much smaller decisions.
That is the whole point of a feature flag, and it is worth the one table it costs.
This is most of what I do as a fractional CTO for founder-led SaaS: take an older codebase and give it the boring infrastructure that lets you ship without holding your breath, without a rewrite, and without breaking what already works. It is the same instinct behind modernizing a legacy PHP application without a full rewrite, small reversible moves over one big risky one.
FAQ
What is the difference between a deploy and a release? A deploy pushes new code to the server. A release is the moment a real user’s experience actually changes. Feature flags separate the two: you can deploy code to production while every user still sees the old behavior, then release it later by flipping a flag. That turns one risky event into two smaller, reversible ones.
Do I need LaunchDarkly or Unleash to use feature flags? No. For a single flag, a full flag platform is over-engineering. One database table with an account ID, a flag key, and an enabled boolean gives you runtime toggles, an audit trail, and instant rollback. Reach for a hosted platform when you have many flags, many teams, or need percentage rollouts and analytics out of the box.
How do you roll a feature out to a percentage of users? Use a deterministic hash of the account ID and flag key, modulo 100, compared against a rollout percentage. Because the hash is deterministic, an account’s bucket never changes, so a user is never enabled one day and disabled the next. That lets you ramp from 5% to 100% without flicker.
How do you avoid feature flags becoming permanent technical debt? Treat rollout flags as temporary and track each one’s owner and expected removal date. At general availability, delete the flag, its rows, and the old code path. Keep rollout flags distinct from permanent kill switches like maintenance mode so cleanup never removes a switch you still need.
Can you roll back a feature without a deploy? Yes, if the flag lives in data rather than in an environment variable or in code. When the decision is a database row, rolling back is a single write you can make instantly, watch, and undo, with no redeploy at the exact moment you least want to be deploying.