qandidate/toggle-bundle
Symfony bundle for Qandidate Toggle that brings feature flags into your app. Define toggles in configuration, check them via the toggle manager, and safely enable or disable features per environment or user without redeploying. Useful for gradual rollouts and A/B tests.
First, the important framing: this is a Symfony bundle, not a Laravel package. composer require qandidate/toggle-bundle in a Laravel app gives you a bundle class, a Symfony DependencyInjection extension, YAML config loading, and a Twig extension — none of which Laravel will ever call. What you actually want as a Laravel developer is the underlying library, qandidate/toggle, which is framework-agnostic plain PHP. Pull that one in and treat the bundle as documentation-by-example for how to wire it up.
The library is small and worth reading in one sitting:
Qandidate\Toggle\Toggle — the entry point; holds a map of toggle name → operator.Qandidate\Toggle\Context — an immutable bag of properties (user_id, environment, locale, …).Qandidate\Toggle\Operator\* — the strategies: AlwaysActiveOperator, AlwaysInactiveOperator, AffirmativeOperator (AND), ContradictoryOperator (NOT), PercentageOperator (sticky bucketing).Qandidate\Toggle\ContextFactory — builds a Context from a PSR-7/Symfony request (the bundle's main convenience).Minimal Laravel wiring, ~15 lines:
// app/Providers/FeatureServiceProvider.php
$this->app->singleton(Toggle::class, function () {
return new Toggle([
'new_checkout' => new PercentageOperator(10, 'user_id'),
'beta_banner' => new AffirmativeOperator([
new AlwaysActiveOperator(), // stand-in for an env check operator
]),
]);
});
$this->app->singleton(Context::class, fn () =>
new Context(['user_id' => (string) optional(auth()->user())->id, 'env' => app()->environment()])
);
First use case: a percentage rollout of a new checkout flow, gated on a stable user_id. That is the 80% case for this library and the one it does best. Reach for it before you build a DB-backed flag table — the config-as-code model forces you to review toggles in PRs.
Then expose a Blade directive mirroring the bundle's Twig extension (Twig/ in the repo — check the exact function name there; it's an isActive-style call):
Blade::if('feature', fn (string $name) => app(Toggle::class)->isActive($name, app(Context::class)));
@feature('new_checkout')
<x-checkout.v2 />
@else
<x-checkout.v1 />
@endfeature
Wrap it, always. Never let Qandidate\Toggle\* types leak into controllers or domain services. Define App\Features\Features with active(string $name): bool, and bind qandidate behind it. Adoption of this package is very low (89 stars, zero dependents), so the probability you migrate to Unleash/Flagsmith/LaunchDarkly in 18 months is high; a one-interface seam makes that a one-file change.
Operators over if statements. The value here is composition, not isActive(). Build named operators as services:
class StaffUserOperator implements Operator
{
public function matches(Context $context): bool
{
return in_array($context->get('user_id'), config('features.staff_ids'), true);
}
}
Then compose: new AffirmativeOperator([new PercentageOperator(50, 'user_id'), new StaffUserOperator()]) reads as "50% of staff."
Context via middleware, not per-call. A ResolveFeatureContext middleware that populates a request-scoped Context (user id, tenant id, environment, plan tier) keeps every call site honest. Ordering matters: it must run after auth.
Kill switches as a distinct category. Keep an ops namespace (ops.payments_provider_b) separate from release toggles (release.new_checkout). Ops toggles are permanent and should be evaluated even on error paths; release toggles are temporary and should be deleted within a sprint or two of hitting 100%. Enforce deletion with a TODO(expires: 2026-09-01) comment convention and a CI grep.
Testing. Two patterns, both essential:
// Unit: deterministic operator, no container
$toggle = new Toggle(['x' => new AlwaysActiveOperator()]);
$this->assertTrue($toggle->isActive('x', new Context([])));
For percentage tests, don't assert on a hardcoded user id (the hash is an implementation detail that can change). Instead assert distribution: evaluate 10,000 synthetic contexts and check the ratio lands in 10% ± 1%.
Debug command. Build php artisan feature:list --user=123 that prints every toggle, its operator class, and the evaluated result for that context. This is the single highest-ROI thing you can add — without it, "why is this on for me?" costs 20 minutes of dd().
Config-as-code review. Since definitions are static config, a toggle change shows up as a diff in a PR. Lean into it: put rollout percentages in config/features.php, not in env vars, so the change is auditable and code-reviewed rather than edited in a dashboard at 2am.
qandidate_toggle: YAML, qandidate.toggle.manager service, Twig feature() function) is Symfony-specific. Translate to config/features.php + a container binding; don't try to shim the bundle in.AffirmativeOperator is strict AND. To get OR you must De Morgan it: new ContradictoryOperator(new AffirmativeOperator([new ContradictoryOperator($a), new ContradictoryOperator($b)])). This is genuinely ugly and is where most bugs live — wrap it in a anyOf() helper on day one.user_id to email, or adding a property to the hash, re-buckets everyone. A user who was in the 10% can silently fall out. Treat the key name as a frozen API once shipped.Toggle::isActive() on an unregistered name raises UnknownToggleException. In a Blade directive that's a 500 on your marketing page. Your Features wrapper should try/catch and default to false, with a log entry — fail closed, not loud.isActive() walks its operator tree. Harmless once per request; catastrophic inside a Blade @foreach over 500 rows. Resolve once in the controller and pass a boolean to the view.features.php is baked by config:cache, so any file-based toggle source read at runtime will go stale. Read toggle definitions from config, not from file_get_contents at runtime.composer.json may pin constraints that conflict with a modern PHP 8.3 + Laravel 11 stack. This is another reason to take qandidate/toggle alone — verify its php constraint before committing.Operator is a one-method interface (matches(Context): bool). Custom operators — tenant allowlists, time-window rollouts, "only in staging" — are trivial and are where this library earns its keep over a boolean column.How can I help you explore Laravel packages today?