Implementing Feature Flags in PHP
AI generated
8.4
PHP, Resilience, Architecture Patterns
Implementing Feature Flags in PHP
in-house, with no third party service

A third party feature flag service comes with a convenient dashboard, but also an extra dependency, ongoing cost, and in many cases user data reaching a third server that was never actually necessary. For a large share of the usual use cases, from simple on-off switches to percentage rollouts with segment targeting, a compact, self-built feature flag system in PHP is entirely sufficient.

15 min read Rollout Percentage, Targeting Testability, Flag Debt

1. Why build an in-house feature flag system instead of using a third party service

There are several reasons to build one in-house: ongoing cost that grows with user count, privacy concerns whenever every flag evaluation ships user context to a third server, and simply the observation that most projects only need on-off switches, percentage rollouts, and simple segment targeting, not the full feature set of an enterprise SaaS product.

A minimal system covers three building blocks: a reliable source for flag definitions, a decision function that evaluates a flag for a given context, and a clean integration point in application code. None of these three building blocks strictly requires an external SaaS product.

2. The basic structure of flag configuration

A simple structure per flag is usually entirely sufficient: a unique key, a global on-off switch, a rollout percentage, a list of allowed segments, a short description, a creation date, and a responsible owner. That structure maps cleanly onto either a plain PHP configuration array or a database table.

A database table with a thin admin interface pays off once non-developers need to toggle flags without a deploy. If it is enough for flags to change only through a code review process, a plain PHP configuration array is entirely sufficient, with no extra infrastructure at all.

3. Implementing rollout percentage deterministically

The obvious but wrong approach is a random roll via random_int() on every single evaluation. The problem: the same user lands on a different random outcome on every request, so a feature is visible on one page load and suddenly gone again on the next, which looks like a bug.

The fix is deterministic hashing: a stable identifier such as the user id is hashed together with the flag key and mapped onto a fixed range between 0 and 99. For the same flag, the same user then lands in the exact same bucket on every evaluation and stays consistently in or out of the rollout, no matter how often that user loads the page.


<?php

declare(strict_types=1);

namespace App\FeatureFlags;

final class FeatureFlagService
{
    public function __construct(private readonly FlagRepository $repository)
    {
    }

    public function isEnabledForUser(string $flagKey, string $userId): bool
    {
        $flag = $this->repository->find($flagKey);

        if ($flag === null || !$flag->enabled) {
            return false;
        }

        if ($flag->rolloutPercentage >= 100) {
            return true;
        }

        // Deterministic bucketing: the same user and flag key always hash
        // to the same bucket, so a user never flips in and out of a
        // rollout between two requests for the same page.
        $bucket = crc32($flagKey . ':' . $userId) % 100;

        return $bucket < $flag->rolloutPercentage;
    }
}

4. User segment targeting through a context object

Beyond a plain rollout percentage, deliberate targeting can be implemented by having a FeatureFlagContext object bundle the relevant traits of a user: customer group, country, beta tester status, or any other business-relevant segment trait.

During evaluation, explicit segment rules take priority over the percentage rollout: a user flagged as a beta tester sees a new feature regardless of the current rollout percentage, for instance, while every other user still gets routed into or out of the rollout through deterministic hashing.


<?php

declare(strict_types=1);

namespace App\FeatureFlags;

final readonly class FeatureFlagContext
{
    public function __construct(
        public string $userId,
        public string $customerGroup = 'default',
        public string $countryCode = 'DE',
        public bool $isBetaTester = false,
    ) {
    }
}

final class TargetingFeatureFlagService
{
    public function __construct(private readonly FlagRepository $repository)
    {
    }

    public function isEnabled(string $flagKey, FeatureFlagContext $context): bool
    {
        $flag = $this->repository->find($flagKey);

        if ($flag === null || !$flag->enabled) {
            return false;
        }

        // Explicit segment rules take priority over the percentage rollout.
        if ($context->isBetaTester && $flag->allowBetaTesters) {
            return true;
        }
        if (in_array($context->customerGroup, $flag->allowedCustomerGroups, true)) {
            return true;
        }

        $bucket = crc32($flagKey . ':' . $context->userId) % 100;
        return $bucket < $flag->rolloutPercentage;
    }
}

5. Clean integration into application code

Access to flags should sit behind an interface or an injectable service instead of raw configuration arrays being read directly from scattered locations across the codebase. A call such as $flags->isEnabled('new_checkout', $context) reads clearly at every call site and reveals nothing about the underlying storage form.

Deeply nested, combined flag conditions spread across many call sites should be avoided. It is better to resolve the decision once, as close to a request's entry point as possible, and pass a plain boolean or a matching strategy object downward, instead of repeating the same flag check in many places.

6. Testability of code behind feature flags

When the feature flag service is injected as a swappable dependency instead of being queried directly as global state, tests can replace it with a fixed, predictable implementation instead of relying on real configuration or database state. That makes both branches, flag on and flag off, explicitly testable independently of each other.

For the rollout percentage and segment logic itself, dedicated unit tests with fixed context objects that cover edge cases pay off too: 0 percent rollout, 100 percent rollout, an exact segment match, and a user outside every segment.

7. Avoiding flag debt: cleaning up old flags consistently

Flag debt builds up when feature flags stay in the code long after a rollout has completed or a final decision has been made. Over time, dead code branches accumulate, raising the cognitive load of reading the code and, in the worst case, producing contradictory flags that overlap each other.

An effective countermeasure is a fixed owner and an expected removal date attached to every flag right at creation, combined with a regular review, for example quarterly, that lists every flag sitting at 0 or 100 percent rollout and actively forces a decision on whether the flag and its dead code branch can finally be removed.

8. Persisting and caching flag values

When flag definitions live in a database, every request and every checked flag adds an extra database query without caching, which produces noticeable load once several flags and high traffic are involved. Briefly caching the flag definitions themselves, for example in APCu or Redis with a TTL of a few seconds to a few minutes, cuts that load significantly without meaningfully hurting freshness.

Rather than purely waiting for the TTL to expire, explicitly invalidating the cache the moment a flag is changed through the admin interface makes more sense. That way a toggle takes effect almost immediately, without unnecessarily hitting the database on every single request.

9. Common mistakes in practical implementations

A common mistake is a non-deterministic rollout, where users seem to randomly flip between the enabled and disabled state across two requests, which looks like a bug and undermines trust in the whole system. Almost as common is neglecting to clean up old flags, leaving the codebase accumulating dead weight over time.

Other frequent mistakes include missing owner and expiry metadata per flag, deeply nested flag checks scattered across many files instead of a single central decision, missing tests for a flag's disabled branch that can then quietly rot unnoticed, and decision functions that trigger side effects such as logging alongside the pure evaluation, needlessly complicating what should be a simple job.

Aspect Naive if constant in code In-house feature flag system
Change without a deploy Not possible Possible via configuration or an admin interface
Rollout percentage Not supported Achievable deterministically per user
Segment targeting Scattered manually across the code Centralized through a context object
Testability of both states Often forgotten Explicitly enforceable via an injectable service
Cleanup after rollout Frequently stays in the code forever Actively managed with an owner and expiry date

Mironsoft

PHP modernization, code quality, and legacy refactoring

Grown PHP code nobody wants to touch anymore?

We modernize PHP codebases to current language standards, introduce static analysis and coding standards, and refactor legacy code step by step without endangering live operations.

Legacy Refactoring

Modernize grown PHP code in a structured, low-risk way.

Establishing Code Quality

Anchor PHPStan, coding standards, and CI checks sustainably in the team.

Version Upgrades

Plan and execute PHP major version upgrades safely, without downtime.

10. Summary

Feature Flags in PHP: The Essentials

Core idea

A compact system of configuration, a decision function, and clean integration replaces many SaaS use cases.

Rollout

Deterministic hashing of user id and flag key gives consistent results per user.

Targeting

A context object bundles segment traits and makes targeting rules centrally changeable.

Hygiene

An owner, an expiry date, and regular reviews prevent old flags from sitting around indefinitely.

11. FAQ: Feature Flags in PHP: The Essentials

1Do I always need an external service for feature flags?
No. For the usual requirements such as on-off switches, percentage rollouts, and simple segment targeting, a compact, self-built system in PHP is usually entirely sufficient.
2How do I make sure a user lands in the same rollout state on every call?
Through deterministic hashing of a stable identifier such as the user id together with the flag key into a fixed value range, instead of rolling the dice again on every evaluation.
3What is a context object in segment targeting?
A value object that bundles the traits of a user relevant to targeting decisions, such as customer group, country, or beta tester status, and gets passed as input to the evaluation function.
4How do I test code that sits behind a feature flag?
By injecting the feature flag service as a swappable dependency, so tests can replace it with a fixed implementation and check both branches, enabled and disabled, independently of each other.
5What is flag debt?
The accumulation of old feature flags that were never removed from the code after a rollout completed or a final decision was made, leaving dead branches and unnecessary complexity behind.
6How do I prevent flag debt?
Through a fixed owner and an expected removal date per flag from the moment it is created, combined with a regular review that actively proposes removing old flags sitting at 0 or 100 percent rollout.
7Where should flag definitions be stored?
For frequent changes made by non-developers, a database table with an admin interface fits well, while a plain PHP configuration array is enough for rare, code-review-gated changes.
8Why should I cache flag definitions?
Because a database lookup per flag check and per request produces noticeable load once several flags and high traffic are involved. Briefly caching flag definitions in a fast store such as APCu or Redis reduces that load significantly.
9What order should evaluating a flag follow?
A common approach checks explicit segment rules first, then the percentage rollout based on deterministic user assignment, and only last the flag's general on-off default value.
10Is a feature flag system only useful for frontend functionality?
No, the same pattern works equally well for backend behavior, such as gradually enabling a new payment provider, a new pricing calculation, or a changed database access path.