All articles
12 September 2026
10 min read

Feature flags in a monolith without framework support

By Tim Rutte, Cloud & Software ArchitectTopicLegacy & Modernization

A glass lottery drum of white balls with a few blue ones; a blue ball is rolling out.

A modernization step that can only be shipped all or nothing is a risk that grows with the size of the step. So in every serious step-by-step modernization the same wish turns up eventually: we want to switch the new path on, for one customer, for one per cent, for ourselves, and switch it off again in seconds.

The tool for that is a feature flag, and in a modern framework it is a package and half an hour. In a grown monolith with no support for it, it looks like a project of its own, which is exactly why it gets left out.

It does not have to be. This article describes an implementation that stands up in a day, works in any PHP application, and takes seriously the part where flags actually fail: removing them again.

What flags are good for in a legacy system

The examples in the literature are about product experiments: two colours for a button, measured by conversion. That is not what this is about, and anybody starting with that expectation builds too much.

In a modernization, flags have three jobs, and all three are boring.

The old and the new path run side by side. The new price calculation is finished, it is tested, and still nobody wants to switch it on for everybody on Monday. With a switch it runs for internal users first, then for one customer, then for everybody. Each of those steps is a decision, not a deployment.

A way back that needs no release. When the new path throws errors at 2pm, the switch is flipped in a minute. Rolling back the entire release takes longer and takes everything else shipped since along with it.

Unfinished work can ship. That is the underrated point. Without flags, work accumulates in a long-lived branch, and at merge time weeks of changes land on top of each other. With flags the half-finished new path ships every week, switched off, and never becomes a large merge.

A comparison that places this: the switch is what separates a rebuild from a migration. A rewrite has no switch, and that is one of the reasons it fails so often.

Four kinds of flag, three with an expiry date

Flags become a problem when they all get treated the same. Four kinds, and the distinction decides how long a flag may live.

Migration flags accompany a rebuild and disappear with it. Lifespan: days to a few weeks. Those are ninety per cent of the flags in a modernization.

Operational flags switch something off when things are on fire: an expensive report, an interface to a partner that is not responding. They live permanently, and that is fine, because they are an operational feature.

Entitlement flags enable features per customer or plan. Those live permanently too, but they do not belong in the same system: that is domain logic, not a migration, and it belongs in the data model rather than in a switch table.

Forgotten flags are not a kind of their own, they are the result. A flag from a 2023 rebuild that is still queried, whose two branches both still exist and where nobody knows which one is right. That is what the section on cleaning up protects against.

The simplest implementation that holds

What it takes is a table, a class and a cache. No service, no package, no admin screen.

CREATE TABLE feature_flag (
  name        VARCHAR(100) NOT NULL PRIMARY KEY,
  active      TINYINT(1)   NOT NULL DEFAULT 0,
  -- A percentage rather than just on/off: gradual is the normal case
  share       TINYINT      NOT NULL DEFAULT 0,
  description VARCHAR(255) NOT NULL,
  -- The most important field in the table, see the section on cleaning up
  remove_by   DATE         NULL,
  changed_at  DATETIME     NOT NULL
) ENGINE=InnoDB;

The lookup has to meet two conditions: it must not measurably slow the request down, and it has to answer stably for the same user. The second is where home-grown solutions usually fail.

final class Switches
{
    private array $flags;

    public function __construct(private PDO $db, private CacheInterface $cache) {}

    public function on(string $name, ?string $key = null): bool
    {
        // Load once per request, not once per call.
        $this->flags ??= $this->cache->get('feature_flags', fn () =>
            $this->db->query('SELECT name, active, share FROM feature_flag')
                     ->fetchAll(PDO::FETCH_UNIQUE), 30);

        $f = $this->flags[$name] ?? null;
        if ($f === null)   return false;  // An unknown flag is off, never on.
        if (!$f['active']) return false;
        if ($f['share'] >= 100) return true;
        if ($key === null) return false;

        // Stable: the same user gets the same answer on every call. With
        // rand(), half of a user's requests would take the old path and half
        // the new one, and every bug report would be worthless. The flag name
        // goes into the hash so the same users do not land in every test
        // group.
        $value = crc32($name . ':' . $key) % 100;
        return $value < $f['share'];
    }
}

Three decisions in there matter more than the rest.

Unknown means off. If the table is unreachable or the flag is missing, the old path runs. A flag whose failure switches the new path on is a switch that falls the wrong way in an emergency.

The cache is short and deliberate. Thirty seconds means a switch-off takes effect within thirty seconds at the latest. That is the price for not asking the database on every request, and thirty seconds is defensible in an emergency. With no cache at all, the table becomes the most-read object in the system.

The share is hashed, not rolled. See the comment in the code. That is the mistake I find most often in home-grown implementations.

Where the switch sits: as far out as possible

The question that decides whether flags can be cleaned up is not how the flag works but where it is queried.

Wrong is a lookup deep in the logic, spread across many places:

// Bad: the flag sits in the middle of the method, and it sits in five
// other places as well.
public function calculate(Basket $b): Amount
{
    if ($this->switches->on('new_discounts', $b->customerId())) {
        $discount = $this->newDiscount($b);
    } else {
        $discount = $this->oldDiscount($b);
    }
    // ... and again further down
}

Right is one lookup at the edge, keeping two complete implementations apart:

// Good: one place decides which implementation is used. Both classes
// satisfy the same interface, neither knows about the flag.
$calculator = $this->switches->on('new_discounts', $customer->id())
    ? new DiscountCalculatorNew($this->tiers)
    : new DiscountCalculatorOld($this->db);

return $calculator->calculate($basket);

The difference shows up when cleaning up. In the first version somebody has to find five places and keep the right branch in each. In the second, one line is replaced and one class is deleted, and the rest of the code never even knew a flag existed.

Where no interface exists yet, that is precisely the first piece of work: a thin layer with room for both paths behind it. It is the same idea as an anti-corruption layer, only pointing inwards.

Flags and the database

A switch reverses code. It does not reverse data, and from that follows the one rule that really is mandatory: both branches have to be able to live with the same data.

In practice that means three things. New columns are added, not repurposed. The old branch ignores what it does not know. And the new branch writes nothing that trips the old branch up when switching back.

Where that is impossible because the new logic produces different data, there are two honest ways out. Either the new branch writes into its own columns at first and only the read path is switched. Or the switch gets a one-way sign: past a certain point switching back is no longer possible, and that is stated as a comment in the code and as a date on the table.

The worst solution is the one nobody thought about. Then the switch goes back at 2pm, and at 3pm it turns out the records created since are invisible to the old branch.

A flag is also the tool for switching off something nobody needs any more: Removing features nobody uses any more.

Cleaning up is the actual work

Every flag is a branch. Twenty flags are a million theoretical states, and nobody knows which of them are tested. A legacy system somebody set out to simplify gets more complicated, and that is why flags have a poor reputation in many companies.

Three things help, and none of them is discipline.

The remove_by field is mandatory. Creating a migration flag means setting a date, usually six to eight weeks out. Without a date the flag does not get created, and operational flags deliberately carry NULL.

A weekly report names the overdue ones, with the owning team. That is a script and a chat message, not a meeting:

SELECT name, description, remove_by,
       DATEDIFF(CURDATE(), remove_by) AS days_overdue
FROM feature_flag
WHERE remove_by IS NOT NULL AND remove_by < CURDATE()
ORDER BY remove_by;

Removal is part of the task, not a new one. A migration counts as finished when the flag and the old branch are gone. As long as that is tracked as a separate, later ticket, it does not happen, because it is never urgent.

As a rule of thumb from practice: more than five to eight simultaneously open migration flags in one team is a sign that too many rebuilds are running at once, not that flags are working well.

For rolling out a new version in steps there is the route through the load balancer rather than the flag: Blue/green and canary without Kubernetes.

When you do not need flags

Three cases where the switch costs more than it gives.

The change is small and backwards compatible. For a corrected calculation producing the same result in a better way, a flag is ceremony. Ship, measure, done.

There is no way to see success. A flag without observation is a switch in the dark. If nobody can measure whether the new path runs better, the order is wrong: measurement first, migration second.

Deployment is a button anyway and reversible in a minute. Then the way back already exists, and a flag is only worth it for cases where something should be switched on gradually. How to get to such a rollout is on its own page, and the order is no accident: rollout first, flags second.

How a step-by-step modernization is cut so that such switches are needed and useful at all is covered on the legacy modernization page.

This article belongs to a series about systems that already exist. The retrospective orders every article in it by situation.