hotflo/feature-toggle-bundle
hotflo/feature-toggle-bundle adds a simple feature-flag layer to PHP applications, letting teams enable or disable features at runtime without redeploying. Define toggles, check them in code, and roll out changes gradually with less risk.
First, a reality check that shapes everything below. The description text mentions "Laravel applications," "Blade directives," and "middleware," but the repository is a Symfony2 bundle wrapping JoshuaEstes/FeatureToggle. The only thing the bundle contributes is Symfony DI wiring (Configuration, *Extension, services.yml), a Bundle subclass, and a Twig extension. There is no service provider, no illuminate/* dependency, and no Blade compiler.
Verify before touching anything:
composer show hotflo/feature-toggle-bundle --all
Expect symfony/* pinned in the 2.x range and a PHP constraint from the PHP 5.x era. On a modern Laravel app (PHP 8.2+, Laravel 10/11/12) this will either fail resolution outright or pull obsolete Symfony components into vendor/. Do not composer require it into the application. Treat the repo as reference source for a pattern, not a runtime dependency.
Where to look first (reading it as reference). It is small — likely a few hundred lines. Read in this order:
DependencyInjection/Configuration.php — the config tree; this is the canonical shape of a "toggle" as data.DependencyInjection/*Extension.php — which services are registered under which IDs/aliases.Resources/config/services.yml — how the underlying FeatureToggle toggles are wired.Twig/ extension — the feature_enabled('...') template helper.vendor/joshuasestes/feature-toggle interfaces (FeatureManager, toggle/strategy contracts) — the only part genuinely worth borrowing.First use case, done properly. The day-one job this package would serve is: dark-ship a new checkout flow, then expose it to a cohort without redeploying. In Laravel you get the same day-to-day ergonomics from Pennant (first-party, Laravel 10.37+) or ~100 lines of your own:
// app/Features/NewCheckout.php
use Laravel\Pennant\Contracts\FeatureScopeable;
class NewCheckout
{
public function resolve(User $user): bool
{
return $user->isBetaTester()
|| crc32($user->id) % 100 < 10; // sticky 10% rollout
}
}
// app/Providers/FeatureServiceProvider.php
public function boot(): void
{
Feature::define('new-checkout', NewCheckout::class);
Feature::define('new-search', fn (User $u) => in_array($u->plan, ['pro', 'enterprise']));
}
At call sites — controller, job, view-model:
Feature::active('new-checkout') ? new NewCheckoutFlow() : new LegacyFlow();
@feature('new-checkout')
<x-new-checkout />
@else
<x-legacy-checkout />
@endfeature
If you specifically want the FeatureManager / named-toggles / pluggable-strategies architecture from JoshuaEstes/FeatureToggle verbatim, expose it behind your own ToggleResolver interface and bind it in a service provider. Do not port the bundle.
Do not store flags in config/*.php if you need runtime flips. config:cache bakes the values into the compiled config, so "enable without redeploy" becomes "enable by redeploying a config cache." Use Pennant's store (database or cache), a features table, or env — not config() calls inside hot paths.
Resolve once per request, not per check. The naive shape hits the store on every Feature::active() call. Pennant's scoped stores handle this; a hand-rolled resolver must read the flag set once and cache it for the request, or you get a query per guarded branch. In hot paths, batch: pull all active flags at the start of the request and check against the array.
Route guarding. Pennant ships EnsureFeaturesAreActive (aliased features); register it in bootstrap/app.php / HTTP kernel and use:
Route::get('/checkout/v2', CheckoutController::class)
->middleware('features:new-checkout');
Keep middleware order in mind: feature middleware should run after auth, because most flags are user-scoped and need an authenticated user to resolve.
Service-layer checks, not scattered ifs. Inject the resolver/manager into services and call sites, and prefer a single named boundary:
public function __construct(private ToggleResolver $toggles) {}
public function handle(): Response
{
return $this->toggles->isActive('new-checkout', $this->user)
? $this->newFlow()
: $this->legacyFlow();
}
This is the ToggleResolver interface recommendation from the technical assessment: call sites depend on the contract, never on Pennant or a fork, so the backend can be swapped (or deleted) later.
Pluggable strategies per flag. Keep resolution logic in a class per feature rather than an if in the provider. That is the one design idea from JoshuaEstes/FeatureToggle worth copying — named toggles resolving against a context. In Pennant this is the resolve() method on a feature class; in a custom implementation it is a Strategy interface with isActive(string $flag, array $context): bool.
Percentage rollouts must be sticky. Hash a stable identifier (user ID, tenant ID) rather than calling rand(), or the same user will flip in and out between requests and destroy the experiment:
return crc32((string) $user->id) % 100 < $percentage;
Strangler adapter for legacy interop. If there is still Symfony2 code in the estate consuming this bundle, do not port the bundle — write an adapter implementing ToggleResolver that proxies to the legacy Symfony2 service during cutover, alongside a Pennant adapter. Delete the legacy adapter when the last Symfony2 process is retired.
Testing patterns. Define a fake resolver or use Pennant's Feature::fake([...]) in tests. Cover the three cases that bite in production: flag on, flag off, and flag missing/undefined. Reset the store between tests (Feature::flushCache()) — leftover active state leaking across tests is the most common CI flake with toggle systems.
composer.json, not the marketing copy — a description promising "Blade directives" on a Symfony2 bundle is a red flag for the rest of the package's documentation.@-suppressed calls will trip PHPStan/Pint/CI quality gates even if composer install succeeds.config:cache defeats runtime flips. See above. If the flags must be live, the source of truth cannot be a PHP config file.@feature-style directive, check it does not collide with Pennant's or a host template's. Use a namespaced directive name in a custom implementation.FeatureManager + named toggle + pluggable strategy interfaces are sound and framework-agnostic. That is the reusable 10%; the Symfony2 wiring is the discardable 90%. If you fork, isolate it behind your own interface so a future swap is a bind() change, not a grep across the codebase.JoshuaEstes/FeatureToggle provides percentage bucketing with mutual exclusion, user-attribute targeting, a "who flipped what when" log, or scheduled rollback. If you need those, the answer is Pennant plus your own governance, or Unleash/LaunchDarkly — not this bundle.How can I help you explore Laravel packages today?