ryangjchandler/laravel-feature-flags
A lightweight feature flag package for Laravel. Define features, check whether they are active from your code, and conditionally render UI with Blade directives. A simple API for toggling features and rolling out changes without redeploying.
composer require ryangjchandler/laravel-feature-flags. Package discovery wires up the service provider and the Feature facade alias automatically; publish the config only if you need to change defaults (php artisan vendor:publish --tag=feature-flags-config).src/Feature.php and the manager class behind the Feature facade. This package is small — the entire contract is essentially "extend Feature, implement one method that returns a bool, ask the facade about it." Reading those two files (plus the make:feature stub) takes ten minutes and tells you more than the README.php artisan make:feature BetaDashboard
namespace App\Features;
use RyanChandler\FeatureFlags\Feature;
class BetaDashboard extends Feature
{
public function resolve(): bool
{
return $this->scope?->isBetaTester() ?? false;
}
}
use RyanChandler\FeatureFlags\Facades\Feature;
if (Feature::active(BetaDashboard::class)) {
// new dashboard
}
@feature(\App\Features\BetaDashboard::class)
<x-new-dashboard />
@else
<x-old-dashboard />
@endfeature
Start with one flag, ship it, then grow. Because flags are classes, the cost of adding the tenth one is near zero.
One class per flag, never boolean config sprawl. The whole value of this package over config('features.x') is that a flag has a name, a place, an owner, and a reviewable diff. Keep resolve() to a few lines and derive the answer from data you already have (role, plan, cohort, tenant settings).
Scope explicitly, per request. Pass the user or tenant in rather than reaching for auth() inside resolve():
Feature::for($request->user())->active(BetaDashboard::class);
This keeps the flag testable, keeps it safe in queued jobs, and makes "what does this flag depend on?" answerable by looking at the call site.
Deterministic percentage rollouts. Stable bucketing is the one piece of logic worth centralising, because a bad hash flaps users in and out:
public function resolve(): bool
{
return crc32($this->scope->id . 'beta-dashboard') % 100 < 10;
}
Include the flag's own name in the hash input so two 10% flags don't select the same 10% of users.
Bridge into Laravel's authorisation layer so the rest of the app doesn't import the facade everywhere:
Gate::define('see-beta-dashboard', fn (User $u) =>
Feature::for($u)->active(BetaDashboard::class)
);
Then @can('see-beta-dashboard'), $this->authorize(...), and policies all work for free.
Route-level gating with the middleware. Register the middleware alias in bootstrap/app.php (or Http/Kernel.php on older versions) and attach it to a route group:
Route::middleware('feature:'.BetaDashboard::class)->group(function () {
Route::get('/dashboard', NewDashboardController::class);
});
Use this for whole surfaces; use Feature::active() inline for small in-page variations. Don't put the middleware on everything — it's a coarse tool.
Compose flags rather than duplicating logic. A flag may consult another flag, but keep the graph a DAG and shallow:
public function resolve(): bool
{
return Feature::for($this->scope)->active(BetaProgramme::class)
&& $this->scope->hasBillingAccount();
}
Cycles here are silent infinite recursion, so sketch the graph before you hit five flags.
Testing. Never write assertions that depend on the current state of a flag's internals. Instead, resolve the flag against a fixture user and assert the flag's decision, and in feature tests swap in a stub:
$this->app->bind(BetaDashboard::class, fn () => new class extends BetaDashboard {
public function resolve(): bool { return true; }
});
If the manager caches instances, clear the resolved cache between tests (see below) or the stub won't take effect.
Gradual removal is part of the pattern. Once a flag is at 100%, delete the class, the @feature branch, and the old code path in the same PR. Flags that outlive their rollout become permanent if statements with a fancy name.
@feature('BetaDashboard'), Feature::active('beta-dashboard'), or a class name with the wrong namespace all fail the same way: the flag reads as inactive (or throws a container error). Import the class or define a const so a rename is a static-analysis error rather than a silent off-switch.tearDown when you swap in stubs.Feature::for() mutates the shared manager. Chaining for() and forgetting to reset leaves the next check scoped to the wrong user. Prefer passing scope at each call site, or wrap in try/finally in loops over users.resolve() runs often — keep it cheap and side-effect free. No queries inside it if you can help it, no logging that matters, absolutely no writes. If a flag needs a DB hit, load the data once and stash it on the scope object.config()/env inside resolve() (accepting a deploy-per-change is not always acceptable) or move that specific flag to Laravel Pennant, which is first-party and persists to a store.feature:... throws a "target class does not exist"-style binding error at route resolution, not at boot. Likewise, if the Blade directive name clashes with another package, whichever registers last wins; grep Blade::directive if @feature starts behaving oddly.dump([$this->scope?->id, $this->resolve()]) inside the feature class — it's faster than tracing the facade, and it immediately reveals the two usual culprits: null scope (unauthenticated request, queued job) and a stale cached answer.How can I help you explore Laravel packages today?