├── src ├── Feature │ ├── Feature.php │ ├── Exception │ │ ├── FeatureNotFoundException.php │ │ └── ResolverNotFoundException.php │ ├── FeatureResolverInterface.php │ ├── DeprecatedFeature.php │ ├── ChainFeatureResolver.php │ ├── ResolvableFeature.php │ ├── FeatureContainerInterface.php │ ├── FeatureFactory.php │ ├── FeatureContainer.php │ └── Resolver.php ├── FeaturesBundle.php ├── Resources │ └── config │ │ └── services.yml ├── Twig │ └── FeaturesExtension.php └── DependencyInjection │ ├── Configuration.php │ ├── FeaturesExtension.php │ └── Compiler │ ├── ReplaceFeaturesCompilerPass.php │ └── ConfigureFeaturesCompilerPass.php ├── LICENSE ├── composer.json └── README.md /src/Feature/Feature.php: -------------------------------------------------------------------------------- 1 | 8 | */ 9 | interface Feature 10 | { 11 | /** 12 | * Check if the feature is active for the current request. 13 | */ 14 | public function isActive(): bool; 15 | } 16 | -------------------------------------------------------------------------------- /src/Feature/Exception/FeatureNotFoundException.php: -------------------------------------------------------------------------------- 1 | 10 | */ 11 | interface FeatureResolverInterface 12 | { 13 | /** 14 | * Check if this feature is active. 15 | */ 16 | public function isActive(array $options = []): bool; 17 | } 18 | -------------------------------------------------------------------------------- /src/Feature/Exception/ResolverNotFoundException.php: -------------------------------------------------------------------------------- 1 | 9 | */ 10 | final class DeprecatedFeature implements Feature 11 | { 12 | /** 13 | * {@inheritdoc} 14 | */ 15 | public function isActive(): bool 16 | { 17 | @trigger_error( 18 | 'Feature with no tag was used, please remove or configure the tag.', 19 | E_USER_NOTICE 20 | ); 21 | 22 | return true; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/Feature/ChainFeatureResolver.php: -------------------------------------------------------------------------------- 1 | feature_container = $feature_container; 14 | } 15 | 16 | /** 17 | * {@inheritdoc} 18 | */ 19 | public function isActive(array $options = []): bool 20 | { 21 | $resolver = new Resolver(); 22 | 23 | foreach ($options as $name => $resolver_options) { 24 | $resolver->addResolver($this->feature_container->getResolver($name), $resolver_options); 25 | } 26 | 27 | return $resolver->resolve(Resolver::STRATEGY_AFFIRMATIVE); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/FeaturesBundle.php: -------------------------------------------------------------------------------- 1 | 12 | */ 13 | final class FeaturesBundle extends Bundle 14 | { 15 | /** 16 | * {@inheritdoc} 17 | */ 18 | public function build(ContainerBuilder $container): void 19 | { 20 | $container->addCompilerPass(new ConfigureFeaturesCompilerPass()); 21 | $container->addCompilerPass(new ReplaceFeaturesCompilerPass(), PassConfig::TYPE_BEFORE_REMOVING); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/Feature/ResolvableFeature.php: -------------------------------------------------------------------------------- 1 | 9 | */ 10 | final class ResolvableFeature implements Feature 11 | { 12 | private $name; 13 | private $resolver; 14 | 15 | public function __construct(string $name, Resolver $resolver) 16 | { 17 | $this->name = $name; 18 | $this->resolver = $resolver; 19 | } 20 | 21 | /** 22 | * Return the name of the feature. 23 | */ 24 | public function getName(): string 25 | { 26 | return $this->name; 27 | } 28 | 29 | /** 30 | * {@inheritdoc} 31 | */ 32 | public function isActive(): bool 33 | { 34 | return $this->resolver->resolve(); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/Feature/FeatureContainerInterface.php: -------------------------------------------------------------------------------- 1 | container = $container; 18 | } 19 | 20 | /** 21 | * {@inheritdoc} 22 | */ 23 | public function getFunctions(): array 24 | { 25 | return [ 26 | new TwigFunction('feature', function ($tag) { 27 | return $this->container->get($tag)->isActive(); 28 | }), 29 | ]; 30 | } 31 | 32 | /** 33 | * {@inheritdoc} 34 | */ 35 | public function getName(): string 36 | { 37 | return 'features_extension'; 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Yannick de Lange 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/DependencyInjection/Configuration.php: -------------------------------------------------------------------------------- 1 | 16 | */ 17 | final class Configuration implements ConfigurationInterface 18 | { 19 | /** 20 | * {@inheritdoc} 21 | */ 22 | public function getConfigTreeBuilder() 23 | { 24 | $builder = new TreeBuilder('features'); 25 | 26 | $builder->getRootNode() 27 | ->children() 28 | ->arrayNode('tags') 29 | ->useAttributeAsKey('name') 30 | ->prototype('array') 31 | ->prototype('array') 32 | ->prototype('variable')->end() 33 | ->end() 34 | ->end() 35 | ->end() 36 | ->end(); 37 | 38 | return $builder; 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/Feature/FeatureFactory.php: -------------------------------------------------------------------------------- 1 | 9 | */ 10 | final class FeatureFactory 11 | { 12 | private $feature_container; 13 | 14 | public function __construct(FeatureContainerInterface $feature_container) 15 | { 16 | $this->feature_container = $feature_container; 17 | } 18 | 19 | /** 20 | * Create and resolve a feature based on the name and the options. 21 | * 22 | * Options array should be structured as follows: 23 | * [ 24 | * resolver1 => [], 25 | * resolver2 => [], 26 | * // etc. 27 | * ] 28 | */ 29 | public function createFeature(string $feature_name, array $options = []): Feature 30 | { 31 | $resolver = new Resolver(); 32 | 33 | foreach ($options as $name => $resolver_options) { 34 | $resolver->addResolver($this->feature_container->getResolver($name), $resolver_options); 35 | } 36 | 37 | return new ResolvableFeature($feature_name, $resolver); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/DependencyInjection/FeaturesExtension.php: -------------------------------------------------------------------------------- 1 | 14 | */ 15 | final class FeaturesExtension extends ConfigurableExtension 16 | { 17 | /** 18 | * {@inheritdoc} 19 | */ 20 | protected function loadInternal(array $config, ContainerBuilder $container): void 21 | { 22 | $tags = []; 23 | foreach ($config['tags'] as $name => $options) { 24 | $escaped_name = md5($name); 25 | 26 | $container->setParameter('features.tags.' . $escaped_name . '.options', $options); 27 | 28 | $tags[$name] = $escaped_name; 29 | } 30 | 31 | $container->setParameter('features.tags', $tags); 32 | 33 | $loader = new YamlFileLoader($container, new FileLocator(__DIR__.'/../Resources/config')); 34 | $loader->load('services.yml'); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/Feature/FeatureContainer.php: -------------------------------------------------------------------------------- 1 | 12 | */ 13 | final class FeatureContainer implements FeatureContainerInterface 14 | { 15 | private $container; 16 | private $features; 17 | private $resolvers; 18 | 19 | /** 20 | * @param ContainerInterface $container 21 | * @param string[] $features 22 | * @param FeatureResolverInterface[] $resolvers 23 | */ 24 | public function __construct(ContainerInterface $container, array $features, array $resolvers) 25 | { 26 | $this->container = $container; 27 | $this->features = $features; 28 | $this->resolvers = $resolvers; 29 | } 30 | 31 | /** 32 | * {@inheritdoc} 33 | */ 34 | public function get($tag): Feature 35 | { 36 | if (!isset($this->features[$tag])) { 37 | throw new FeatureNotFoundException($tag); 38 | } 39 | 40 | return $this->container->get($this->features[$tag]); 41 | } 42 | 43 | /** 44 | * {@inheritdoc} 45 | */ 46 | public function getResolver(string $name): FeatureResolverInterface 47 | { 48 | if ($name === 'chain') { // Special resolver. 49 | return new ChainFeatureResolver($this); 50 | } 51 | 52 | if (!isset($this->resolvers[$name])) { 53 | throw new ResolverNotFoundException($name); 54 | } 55 | 56 | return $this->resolvers[$name]; 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "yannickl88/features-bundle", 3 | "description": "Symfony bundle for managing feature tags", 4 | "license": "MIT", 5 | "type": "symfony-bundle", 6 | "keywords": [ 7 | "Symfony", 8 | "Features", 9 | "Bundle" 10 | ], 11 | "authors": [ 12 | { 13 | "name": "Yannick de Lange", 14 | "email": "yannickl88@gmail.com", 15 | "homepage": "http://yannickl88.nl/" 16 | } 17 | ], 18 | "require": { 19 | "php": "^8.1", 20 | "symfony/config": "^5.4||^6.0", 21 | "symfony/dependency-injection": "^5.4||^6.0", 22 | "symfony/finder": "^5.4||^6.0", 23 | "symfony/framework-bundle": "^5.4||^6.0", 24 | "symfony/http-kernel": "^5.4||^6.0" 25 | }, 26 | "require-dev": { 27 | "phpspec/prophecy-phpunit": "^2.0", 28 | "phpunit/phpunit": "^9.5.8", 29 | "symfony/twig-bundle": "^5.4||^6.0", 30 | "symfony/yaml": "^5.4||^6.0", 31 | "twig/twig": "^3.4.3" 32 | }, 33 | "suggest": { 34 | "twig/twig": "Allows use of features in twig templates through the FeaturesExtension" 35 | }, 36 | "minimum-stability": "dev", 37 | "prefer-stable": true, 38 | "autoload": { 39 | "psr-4": { 40 | "Yannickl88\\FeaturesBundle\\": "src/" 41 | } 42 | }, 43 | "autoload-dev": { 44 | "psr-4": { 45 | "Yannickl88\\FeaturesBundle\\": "test/" 46 | } 47 | }, 48 | "archive": { 49 | "exclude": [ 50 | "/test" 51 | ] 52 | }, 53 | "extra": { 54 | "branch-alias": { 55 | "dev-master": "2.0-dev" 56 | } 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/Feature/Resolver.php: -------------------------------------------------------------------------------- 1 | 9 | */ 10 | class Resolver 11 | { 12 | const STRATEGY_AFFIRMATIVE = 'affirmative'; 13 | const STRATEGY_UNANIMOUS = 'unanimous'; 14 | 15 | /** 16 | * @var FeatureResolverInterface[] 17 | */ 18 | private $resolvers = []; 19 | 20 | /** 21 | * @var array 22 | */ 23 | private $resolver_options = []; 24 | 25 | public function addResolver(FeatureResolverInterface $resolver, array $options = []): void 26 | { 27 | $key = spl_object_hash($resolver); 28 | 29 | $this->resolvers[$key] = $resolver; 30 | $this->resolver_options[$key] = $options; 31 | } 32 | 33 | /** 34 | * Resolve the final value. 35 | * 36 | * @throws \InvalidArgumentException when strategy is unknown. 37 | */ 38 | public function resolve(string $strategy = self::STRATEGY_UNANIMOUS): bool 39 | { 40 | switch ($strategy) { 41 | case self::STRATEGY_AFFIRMATIVE: 42 | return $this->resolveAffirmative(); 43 | case self::STRATEGY_UNANIMOUS: 44 | return $this->resolveUnanimous(); 45 | } 46 | 47 | throw new \InvalidArgumentException(sprintf('The strategy "%s" is not supported.', $strategy)); 48 | } 49 | 50 | /** 51 | * Resolve the feature where at least one voter needs to resolve true for 52 | * the feature to be active. 53 | */ 54 | private function resolveAffirmative(): bool 55 | { 56 | foreach ($this->resolvers as $key => $resolver) { 57 | if ($resolver->isActive($this->resolver_options[$key])) { 58 | return true; 59 | } 60 | } 61 | 62 | return false; 63 | } 64 | 65 | /** 66 | * Resolve the feature where all the voters needs to resolve true for 67 | * the feature to be active. 68 | */ 69 | private function resolveUnanimous(): bool 70 | { 71 | foreach ($this->resolvers as $key => $resolver) { 72 | if (!$resolver->isActive($this->resolver_options[$key])) { 73 | return false; 74 | } 75 | } 76 | 77 | return true; 78 | 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /src/DependencyInjection/Compiler/ReplaceFeaturesCompilerPass.php: -------------------------------------------------------------------------------- 1 | 17 | */ 18 | final class ReplaceFeaturesCompilerPass implements CompilerPassInterface 19 | { 20 | /** 21 | * {@inheritdoc} 22 | */ 23 | public function process(ContainerBuilder $container): void 24 | { 25 | $tags = $container->getParameter('features.tags'); 26 | $services = $container->findTaggedServiceIds('features.tag'); 27 | 28 | foreach ($services as $id => $options) { 29 | if (!isset($options[0]['tag'])) { 30 | throw new InvalidArgumentException(sprintf( 31 | 'The value for "tag" is missing in the tag "features.tag" for service "%s".', 32 | $id 33 | )); 34 | } 35 | if (count($options) != 1) { 36 | throw new InvalidArgumentException(sprintf( 37 | 'Multiple "features.tag" tags found for service "%s", only one is allowed per service.', 38 | $id 39 | )); 40 | } 41 | 42 | $tag = $options[0]['tag']; 43 | 44 | if (!array_key_exists($tag, $tags)) { 45 | throw new InvalidArgumentException(sprintf( 46 | 'Unknown tag "%s" used in the "feature.tag" of service "%s".', 47 | $tag, 48 | $id 49 | )); 50 | } 51 | 52 | $definition = $container->getDefinition($id); 53 | 54 | foreach ($definition->getArguments() as $index => $argument) { 55 | if (! $argument instanceof Reference 56 | || !in_array($argument->__toString(), ['features.tag', DeprecatedFeature::class]) 57 | ) { 58 | continue; 59 | } 60 | 61 | $definition->replaceArgument($index, new Reference('features.tag.' . $tags[$tag])); 62 | } 63 | } 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/DependencyInjection/Compiler/ConfigureFeaturesCompilerPass.php: -------------------------------------------------------------------------------- 1 | 21 | */ 22 | final class ConfigureFeaturesCompilerPass implements CompilerPassInterface 23 | { 24 | /** 25 | * {@inheritdoc} 26 | */ 27 | public function process(ContainerBuilder $container) 28 | { 29 | // Configure the resolvers. 30 | $resolvers = $this->configureResolvers($container); 31 | 32 | // Configure the tags. 33 | $this->configureTags($container, $resolvers); 34 | } 35 | 36 | /** 37 | * Configure the resolvers in the service container. 38 | * 39 | * @param ContainerBuilder $container 40 | * @return string[] 41 | */ 42 | private function configureResolvers(ContainerBuilder $container): array 43 | { 44 | $services = $container->findTaggedServiceIds('features.resolver'); 45 | $resolvers = ['chain' => true]; 46 | 47 | foreach ($services as $id => $options) { 48 | foreach ($options as $tag_options) { 49 | if (!isset($tag_options['config-key'])) { 50 | throw new InvalidArgumentException(sprintf( 51 | 'The value for "config-key" is missing in the tag "features.tag" for service "%s".', 52 | $id 53 | )); 54 | } 55 | $config_key = $tag_options['config-key']; 56 | 57 | if (isset($resolvers[$config_key])) { 58 | throw new InvalidArgumentException(sprintf( 59 | 'The config-key "%s" is already configured by resolver "%s".', 60 | $config_key, 61 | (string) $resolvers[$config_key] 62 | )); 63 | } 64 | $resolvers[$config_key] = new Reference($id); 65 | } 66 | } 67 | $container 68 | ->getDefinition(FeatureContainer::class) 69 | ->replaceArgument(2, $resolvers); 70 | 71 | return $resolvers; 72 | } 73 | 74 | /** 75 | * Configure the tags in the service container. 76 | * 77 | * @param ContainerBuilder $container 78 | * @param string[] $configured_resolvers 79 | * @return string[] 80 | */ 81 | private function configureTags(ContainerBuilder $container, array $configured_resolvers): array 82 | { 83 | $tags = $container->getParameter('features.tags'); 84 | $all = []; 85 | 86 | foreach ($tags as $tag => $param_name) { 87 | $options = $container->getParameter('features.tags.' . $param_name . '.options'); 88 | 89 | // check if all the resolvers are actually configured 90 | if (count($missing = array_diff(array_keys($options), array_keys($configured_resolvers))) > 0) { 91 | throw new InvalidArgumentException(sprintf( 92 | 'Unknown resolver(s) %s configured for feature tag "%s".', 93 | trim(json_encode(array_values($missing)), '[]'), 94 | $tag 95 | )); 96 | } 97 | 98 | $definition = new Definition(Feature::class); 99 | $definition->setPublic(true); 100 | $definition->setFactory([new Reference(FeatureFactory::class), 'createFeature']); 101 | $definition->setArguments([$tag, $options]); 102 | 103 | $container->setDefinition('features.tag.' . $param_name, $definition); 104 | 105 | $all[$tag] = 'features.tag.' . $param_name; 106 | } 107 | 108 | $container 109 | ->getDefinition(FeatureContainer::class) 110 | ->replaceArgument(1, $all); 111 | 112 | return $tags; 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # features-bundle 2 | This Symfony bundle provides a way of managing features within a project. A common use-case is to have a certain feature only active under certain condition. Examples would be that you want to activate a feature when the use has a certain role, or when you are not in a production environment (think of testing). 3 | 4 | With this bundle you can configure features to be active or inactive. Using resolvers you decide when a feature is active or not. 5 | 6 | Requirements: 7 | - PHP 7.3 or higher 8 | - Symfony 4.2 or higher 9 | 10 | Recommended installation is via composer: `composer require yannickl88/features-bundle`. 11 | 12 | After that, you need to register the bundle in the kernel of your application: 13 | 14 | ```php 15 | request_stack = $request_stack; 72 | } 73 | 74 | /** 75 | * {@inheritdoc} 76 | */ 77 | public function isActive(array $options = []): bool 78 | { 79 | // Feature is inactive when there is no request 80 | if (null === $request = $this->request_stack->getMasterRequest()) { 81 | return false; 82 | } 83 | 84 | // $options contains ["beta", "on"] for the 'beta' feature tag 85 | list($key, $expected_value) = $options; 86 | 87 | return $request->get($key) === $expected_value; 88 | } 89 | } 90 | ``` 91 | Now we can start using the feature in our code. So if I want to check for a feature I can inject it as follows: 92 | ```yml 93 | services: 94 | app.some.service: 95 | class: App\Some\Service 96 | arguments: 97 | - '@Yannickl88\FeaturesBundle\Feature\Feature' 98 | tags: 99 | - { name: features.tag, tag: beta } 100 | ``` 101 | Notice here that we do not inject the feature directly, but tag the service. The bundle will replace the feature for you. So you can use it as follows in your code: 102 | ```php 103 | namespace App\Some; 104 | 105 | use Yannickl88\FeaturesBundle\Feature\Feature; 106 | 107 | class Service 108 | { 109 | private $feature; 110 | 111 | public function __construct(Feature $feature) 112 | { 113 | $this->feature = $feature; 114 | } 115 | 116 | public function someMethod(): void 117 | { 118 | if ($this->feature->isActive()) { 119 | // do some extra beta logic when this feature is active 120 | } 121 | } 122 | } 123 | ``` 124 | So if I now add `?beta=on` to my URL. The feature will trigger. 125 | 126 | __Note:__ If you remove the tag, it will inject a deprecated feature. This deprecated feature will trigger a warning when the `isActive` is used so you will quickly see where unused feature are used. 127 | 128 | # Twig 129 | If it also possible to check a feature in your twig templates. Simply use the `feature` function to check if a feature is enabled. 130 | 131 | ```twig 132 | {% if feature("beta") %} 133 | {# do some extra beta logic when this feature is active #} 134 | {% endif %} 135 | ``` 136 | 137 | # Advanced Topics 138 | It is possible to configure multiple resolvers per feature tag. You can simply keep adding more in the `config.yml`. So in the example we can extend it to: 139 | ```yml 140 | features: 141 | tags: 142 | beta: 143 | request: ["beta", "on"] 144 | other: ~ 145 | more: ["foo"] 146 | ``` 147 | All resolvers must now resolve to `true` in order for this feature to be active. This is usefull if you want to check for multiple conditions. 148 | 149 | Furthermore, if you want to have multiple resolvers where only one needs to resolve to `true`, you can use the chain resolver. This can be done as follows: 150 | ```yml 151 | features: 152 | tags: 153 | beta: 154 | chain: 155 | request: ["beta", "on"] 156 | other: ~ 157 | more: ["foo"] 158 | ``` 159 | Notice here we have as resolver `chain` and under this we have your config as before. 160 | --------------------------------------------------------------------------------