Skip to content

[FeatureFlag] Propose a simple version #53213

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 46 commits into
base: 7.3
Choose a base branch
from

Conversation

Jean-Beru
Copy link
Contributor

@Jean-Beru Jean-Beru commented Dec 26, 2023

Q A
Branch? 7.3
Bug fix? no
New feature? yes
Deprecations? no
Issues
License MIT

Simple FeatureFlag component

Introduction

Co-authored with @Neirda24 who is also at the origin of this component.

Based on the initial pull request #51649:

Currently, there is no straightforward method to conditionally execute parts of the source code based on specific
contexts. This PR tries to solve this issue by integrating some easy way to check is this or that feature should be
enabled.

First check out Martin Fowler's article about different use cases for feature flag (a.k.a. feature toggling).
He categorizes feature flag like this:

  • Experiment: show a beta version of your website to users who subscribed to.
  • Release: deploy a new version of your code but keep the old one to compare them easily and rollback quickly if needed
    or control when a feature is released.
  • Permission: grant access to a feature for paid accounts using the Security component.
  • Ops: remove access to a consuming feature if server ressources are low (a k.a. kill switch). During Black Friday for
    example it is common to deactivate certain features for Ops because the load will be on other pages.

There are already some libraries / bundles out there but they either lack extensibility or are locked in to a SAAS
tool (Unleash, Gitlab (which uses Unleash), ...).

Proposal

The FeatureFlag component provides a service that checks if a feature is enabled. A feature is a callable which returns
a value compared to the expected value to determine is the feature is enabled (mostly a boolean but not limited to).

Since every declared feature is a callable, they are only resolved when needed.

The component

A service implementing the ProviderInterface is responsible for giving a callable from a feature name. This callable,
which does not accept any argument (for now), can be called to get the feature value.

The FeatureChecker is responsible for checking if a feature is enabled from its name. It uses a ProviderInterface to
retrieve the corresponding feature, resolves it and compare it to the expected value (true by default).

For now, two providers are currently implemented:

  • InMemoryProvider which contains the features passed in its constructor
  • ChainProvider which aggregates multiple providers and iterates through them to retrieve a feature. It allows
    combining different sources of feature definitions, such as in-memory, database, or external services.

Usage

Declare your features using a provider and pass it to the checker:

use Symfony\Component\FeatureFlag\FeatureChecker;
use Symfony\Component\FeatureFlag\Provider\InMemoryProvider;

$provider = new InMemoryProvider([
    'weekend_feature' => fn() => date('N') >= 6, // will only be activated on weekends
    'feature_version' => fn() => random_int(1, 3), // will return a random version to use
]);
$featureChecker = new FeatureChecker($provider);

Of course, invokable classes can be used:

use Symfony\Component\FeatureFlag\FeatureChecker;
use Symfony\Component\FeatureFlag\Provider\InMemoryProvider;

final class XmasFeature
{
    public function __invoke(): bool
    {
        return date('m-d') === '12-25';
    }
}

$features = new InMemoryProvider([
    'xmas-feature' => new XmasFeature(),
]);
$featureChecker = new FeatureChecker($features);

Check the feature's value:

$featureChecker->isEnabled('weekend_feature'); // returns true on weekend
$featureChecker->isEnabled('not_a_feature'); // returns false

Once the feature's value is resolved, it is stored to make it immutable:

$featureChecker->getValue('feature_version'); // returns a random version between 1 and 3
$featureChecker->getValue('feature_version'); // returns the previous value

FrameworkBundle integration

In addition, the #[AsFeature] attribute can be used to make a feature from a service:

// Will create a feature named "UserFeature"
use App\Security\User;
use Symfony\Bundle\SecurityBundle\Security;

#[AsFeature]
final class UserFeature
{
    public function __construct(private readonly Security $security) {}

    public function __invoke(?User $user): bool
    {
        if (!($user = $this->security->getUser()) instanceof User) {
            return false;
        } 
    
        return $user->hasGroup(1);
    }
}

In fact, any callable can be declared as a feature using this attribute.

// Will create three features named "AppFeatures::alpha", "AppFeatures::beta", "gamma" and "delta"
#[AsFeature(name: 'delta', method: 'delta')]
final class AppFeatures
{
    #[AsFeature]
    public function alpha(): bool
    {
        // ...
    }
    
    #[AsFeature]
    public function beta(): bool
    {
        // ...
    }
    
    #[AsFeature(name: 'gamma')]
    public function gamma(): bool
    {
        // ...
    }
    
    public function delta(): bool
    {
        // ...
    }
}

Thanks to the ExpressionLanguage component, feature flags can be used in Route definitions.

class MyController
{
    #[Route(path: '/', name: 'homepage_new', condition: 'feature_is_enabled("beta")', priority: 50)]
    public function homepageNew(FeatureCheckerInterface $featureChecker): Response
    {
        // Will be called if the "beta" feature flag is enabled
    }
   
    #[Route(path: '/', name: 'homepage')]
    public function homepage(FeatureCheckerInterface $featureChecker): Response
    {
        // Will be called if the "beta" feature flag is not enabled
    }

WebProfilerBundle integration

The Feature Flag component integrates with the WebProfilerBundle to provide insights into feature evaluations:

Resolved features in the toolbar:

Resolved features in the toolbar

Resolved features in the panel:

Resolved features in the panel

Unresolved features in the panel:

Unresolved features in the panel:

Disabled FeatureFlag menu in the panel:

Resolved feature details in the panel

TwigBridge integration

Feature flags can be checked in Twig template if needed.

Beta features enabled: {{ feature_is_enabled('beta')  ? '' : '' }}

App version used: {{ feature_get_value('app_version')  ? '' : '' }}

Going further (to discuss in dedicated PRs)

  • Add documentation
  • Integrate popular SaaS tool as bridges (Gitlab,
    LaunchDarkly, etc.) by adding a dedicated provider
  • Propose pre-implemented strategies like opening a feature to a percentage of visitors
  • Resolve feature's arguments like it is done for controllers
  • Add a #[Feature] to restrict access to a controller
  • Declare features from the semantic configuration using the ExpressionLanguage component
  • Add a debug:feature to make feature debugging easier

About caching

Immutability

The resolved value of a feature must be immutable during the same request to avoid invalid behaviors. That's why the
FeatureChecker keeps values internally.

Heavy computing

Even if features are lazy loaded, they can be resolved in every request to determine if a controller can be accessed. It
could be problematic when the provider retrieves this value from a database or an external service. Depending on the
solution chosen to store features, the logic to cache value may differ. That's why the value caching between requests
should be handled by providers.

For instance, Unleashed can define some strategy configuration that can be cached and used to resolve feature values.
See https://github.com/Unleash/unleash-client-php/tree/main/src/Strategy.

@OskarStark
Copy link
Contributor

I like this simplified version, which is all, I would need in the first step ❤️ Thank you!

@noahlvb
Copy link

noahlvb commented Jan 2, 2024

In the going further section Gitlab is mentioned. The Gitlab feature flag is based on and compatible with the Unleash api spec. So maybe it is worth to implement that api since a few services are compatible with it.

@Neirda24
Copy link
Contributor

Neirda24 commented Jan 2, 2024

@noahlvb : yes we have this in mind and plan on implementing :

  • unleash
  • gitlab
  • DarkLaunchy
  • Maybe Jira

and more if further contributions.

@OskarStark
Copy link
Contributor

Bridges to providers should be proposed in a follow-up PR

@Jean-Beru Jean-Beru force-pushed the rfc/simple-feature-flag branch from 04de789 to 0c4aec4 Compare January 2, 2024 12:43
@yceruto
Copy link
Member

yceruto commented Jan 2, 2024

About the proposed API, I have some suggestions:

$featureChecker->getValue('feature_version'); // returns a random version between 1 and 3

If we take the stricter path of the concept, a feature flag should deal only with a boolean result at the end. So, I don't think the getValue() method should be public. Otherwise, it can be used as a configuration fetcher rather than a toggle checker, defeating its purpose. I would make this method private or protected instead, in charge of resolving the boolean toggle value.

$features = new FeatureRegistry(['feature_version' => fn() => random_int(1, 3)]);
$featureChecker->isEnabled('feature_version', 1); // returns true if the random version is 1

Following the same principle, the toggle callable should always return a boolean value, receiving the arguments necessary to compute it. Thus, we can pass all arguments from isEnabled('feature', ...args) to the toggle decider rather than a hardcoded equal condition, which may not be sufficient.

Example:

$features = new FeatureRegistry([
    'feature_version' => fn (int $v): bool => random_int(1, 3) === $v,
]);
$checker = new FeatureChecker($features);

if ($checker->isEnabled('feature_version', 1)) {
   // ...
}

We could also add isDisabled(...) method to improve the semantic of negative conditions.

This way feature deciders (the callables here) have full control over the computation of the feature value, and the feature checker will be responsible for checking/caching the value only.

@nicolas-grekas
Copy link
Member

On my side I think it's important to support non-boolean values from day 1.
Maybe it can be abused as a config fetcher, but A/B/C testing needs this for example.
(and L has it ;) )

@Jean-Beru Jean-Beru force-pushed the rfc/simple-feature-flag branch from 47d16ba to 2f490f8 Compare May 2, 2025 20:31
@Jean-Beru Jean-Beru force-pushed the rfc/simple-feature-flag branch from 66c5c73 to 802cd2c Compare May 5, 2025 08:56
@ajgarlag
Copy link
Contributor

ajgarlag commented May 5, 2025

A tricky way is to use a repository in your composer.json to download the component:

    "require": {
        "symfony/feature-flags": "dev-rfc/simple-feature-flags"
    }
    "repositories": [
        {
            "type": "package",
            "package": {
                "name": "symfony/feature-flags",
                "version": "dev-rfc/simple-feature-flags",
                "dist": {
                    "url": "https://github.com/Jean-Beru/symfony/archive/refs/heads/rfc/simple-feature-flag.zip",
                    "type": "zip"
                },
                "autoload": {
                    "psr-4": { "Symfony\\Component\\FeatureFlag\\": "src/Symfony/Component/FeatureFlags" }
                }
            }
        }
    ]

@Jean-Beru What do you think of the approach used in jwage/phpamqplib-messenger? You could release it as a third-party bundle that could be subsequently integrated it into the Symfony Core.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.