em411/feature-flag-bundle
A Symfony bundle for feature flags: enable or disable code paths based on context-aware callables. This is a Symfony 4.4 / PHP 7.4 backport of ajgarlag/feature-flag-bundle, keeping the same API and namespace for a drop-in upgrade from the upstream package.
Before you install anything: this is not a Laravel package. It's a Symfony 4.4 / PHP 7.4 bundle with a hard dependency on Symfony components (symfony/framework-bundle, symfony/dependency-injection, Twig, Doctrine annotations). Dropping it into a modern Laravel app will not autoload, will not register a service provider, and will almost certainly create Composer conflicts against illuminate/* and current symfony/* versions. Your realistic options are:
FeatureChecker + ProviderInterface + a chain provider) into a Laravel service provider, or use Laravel Pennant and keep this README as the design reference.If you are in case 2, the install is two steps:
composer require em411/feature-flag-bundle
// config/bundles.php
return [
// ...
Ajgarlag\FeatureFlagBundle\FeatureFlagBundle::class => ['all' => true],
];
Where to look first, in order:
FeatureCheckerInterface — the only thing you inject in application code. It exposes isEnabled(string $name) and, judging by the Twig feature_get_value() function, a value-returning counterpart.Provider\ProviderInterface — get(string $featureName): ?callable. This is the extension point; everything else is wiring.Attribute\AsFeature — despite the name, it's a Doctrine annotation (@AsFeature), not a PHP 8 attribute. PHP 7.4 has no attributes.First use case: gate a checkout flow. Declare a feature class, inject the checker, fail with a 404 when it's off.
namespace App\Feature;
use Ajgarlag\FeatureFlagBundle\Attribute\AsFeature;
/**
* @AsFeature("new_checkout")
*/
final class NewCheckoutFeature
{
public function __invoke(): bool
{
return getenv('NEW_CHECKOUT') === '1';
}
}
public function index(): Response
{
if (!$this->featureChecker->isEnabled('new_checkout')) {
throw new NotFoundHttpException();
}
// ...
}
That's the whole loop. Everything below is variations on it.
Declaring features. Four shapes, pick deliberately:
// 1. Invokable class, explicit name (preferred)
/** @AsFeature("xmas") */
final class XmasFeature { public function __invoke(): bool { ... } }
// 2. Class + method property
/** @AsFeature(name="xmas", method="isXmas") */
final class XmasFeature { public function isXmas(): bool { ... } }
// 3. Method-level, explicit name
/** @AsFeature(name="weekend") */
public function isWeekend(): bool { ... }
// 4. Method-level, no name → "App\Feature\FeatureService::isAnotherFeature"
/** @AsFeature */
public function isAnotherFeature(): bool { ... }
Prefer explicit names in cases 1–3. The defaults are FQCN-derived and silently change if you rename or move the class.
Multiple features on one class. Doctrine annotations can't repeat, so use the service tag instead of stacking @AsFeature:
services:
App\Feature\CheckoutFlags:
tags:
- { name: 'ajgarlag.feature_flag.feature', feature: 'new_checkout' }
Custom providers — the pattern you'll actually spend time on. Any service implementing ProviderInterface is autoconfigured. Return a closure when you own the feature, null when you don't:
final class DoctrineProvider implements ProviderInterface
{
public function __construct(private FeatureAssignmentRepository $repo) {}
public function get(string $featureName): ?callable
{
$context = ['user' => $this->security->getUser()?->getUsername()];
return fn () => $this->repo->featureIsEnabled($featureName, $context);
}
}
Three provider archetypes cover most needs: static/env (ServiceProvider / the built-in in-memory one), database (assignment table — the Doctrine example), and remote (Unleash/GitLab). The README's GitLab example is worth skimming even if you don't use Unleash, because it shows the caching-service and context-object wiring you'll need for any HTTP-backed provider.
Priority and the chain. Higher priority on the ajgarlag.feature_flag.provider tag is checked first; first provider that returns a feature wins. Use this to put a cheap override provider (an admin "kill switch" from env, or a test double) ahead of expensive DB/API providers.
Route gating. Symfony 4.4 can't evaluate services in route.condition. Two options:
kernel.controller listener that maps controller/attribute names to features and throws NotFoundHttpException centrally. Once you have more than ~3 gated routes, do this instead of copy-pasting the guard.Twig. feature_is_enabled('x') and feature_get_value('x') for template-level toggles. feature_get_value is also your gradual-rollout lever: return a bucket integer or variant string from the callable and compare in Twig (== 'some_value').
Wrap it. Inject FeatureCheckerInterface through one thin application-level service (App\Flags\Flags) rather than directly into every controller. When you eventually migrate to Laravel Pennant or the upstream PHP 8 attribute API, you change one class instead of fifty constructors.
Dependency conflicts are the first wall. A Laravel app on PHP 8.1+ will fail composer require on PHP constraint, Symfony version pins, or Twig ^2.12 | >=3.0,<3.7. Don't fight it — the package is not intended for that runtime.
EOL posture is explicit. Symfony 4.4 and PHP 7.4 are both EOL; the maintainers had to set config.audit.block-insecure: false in CI just to resolve dependencies. Every composer install will report security advisories. Fine for an already-frozen legacy app, unacceptable for anything you plan to keep shipping on. Treat that as a scheduling constraint, not a nuisance.
The namespace does not match the package name. It's em411/feature-flag-bundle on Packagist but Ajgarlag\FeatureFlagBundle\ in PHP, deliberately, for drop-in compatibility with the upstream. Consequences:
#[AsFeature] (PHP 8 attributes). Here it is @AsFeature in a docblock.ajgarlag/feature-flag-bundle in the same project. Identical class namespaces and class names → fatal redeclaration or unpredictable autoload resolution.Annotations need infrastructure. @AsFeature requires doctrine/annotations and a configured annotation reader. If the legacy app has no Doctrine annotation setup, class-level autoconfiguration won't fire and features will appear "missing" with no error — just isEnabled() returning false. Symptom to watch for: the profiler shows the feature as unresolved even though the class exists.
Providers that always return a closure shadow everything below them. get() must return null for features it doesn't know. A provider that returns a closure unconditionally becomes a catch-all and the chain silently degrades to a single provider. Also: a provider returning a closure has "won" — the chain does not compare values, only ownership.
No built-in result caching. The callable is invoked every time isEnabled() / feature_get_value() is called. A DB-backed provider called inside a Twig loop is an N+1. Memoize inside your provider (or decorate FeatureCheckerInterface with a per-request cache) as a matter of course.
Context capture timing. The closure captures context at get() time. In a Symfony request this is usually the same moment as evaluation, so it works — but if you resolve the user lazily (sub-requests, CLI, message consumers), capture inside the closure body rather than before it.
Failure mode is undefined by you, not by the bundle. If a provider throws (DB down, Unleash API timeout), the exception propagates. Decide explicitly per provider: fail-open for UI cosmetics, fail-closed for anything that changes money or data — and wrap the callable in try/catch to enforce it. The default "let it throw" is a 500, which is a valid but rarely intended choice.
Naming drift. Default feature names are FQCN or Class::method. Refactor a class, silently lose a feature. Explicit names everywhere is cheap insurance.
Service id archaeology. The in-memory provider service id is ajgarlag.feature_flag.provider.in_memory but it's actually backed by ServiceProvider (the changelog calls this out as a leftover). Don't infer implementation from the id when overriding services in services_test.yaml.
Testing. Override ChainProvider (or prepend a test provider with high priority) in the test container and return static closures. Never let the test suite hit real DB/HTTP providers — those calls are uncached and will make the suite slow and flaky.
Profiler. The bundle ships a web profiler data collector showing resolved feature values per request. In dev this is your primary debugging tool for "why is this flag off" — check the resolved value there before adding dump() calls.
Migration path is a codemod. When you move to PHP 8.1+/Symfony 6 and switch to upstream, every @AsFeature docblock becomes a #[AsFeature] attribute and the one-annotation-per-method limitation disappears. Keeping feature names explicit and the checker behind your own service makes that a mechanical change rather than a rewrite.
How can I help you explore Laravel packages today?