trinetus / feature-flags
Simple Feature Flags implementation for Laravel project.
Requires
- php: ^8.3
- laravel/framework: ^12.0|^13.0
Requires (Dev)
- larastan/larastan: ^3.1
- laravel/pint: ^1.30
- orchestra/testbench: ^10.0|^11.0
- pestphp/pest: ^4.0
- pestphp/pest-plugin-laravel: ^4.0
README

Feature Flags
UI-agnostic Feature Flags for Laravel applications.
All logic lives in plain services, the whole lifecycle is manageable from artisan commands, and the package ships no routes, no controllers and no views — if you want a UI, you build a thin layer (Livewire component, API controllers for a React/Vue admin, Filament page, …) on top of the public service API.
A feature flag can be:
- global —
on/offfor everyone, - targeted — enabled for selected instances of any scope you define (users, roles, organizations, teams… any Eloquent model, configured by you),
- inherited — a model can inherit flags enabled for its parents (e.g. a user inherits their team's flags).
Motivation
This package is the successor of trinetus/laravel-feature-flag (a fork of
friendsofcat/laravel-feature-flag). The fork works, but has structural limits:
- Hardcoded scopes — flags can only target
users(by e-mail),rolesandteams. Every other domain entity (organization, tenant, branch, …) has to be squeezed into one of those names viagetFieldValueForFeatureFlags()mappings in every model. Here, scope names are free-form configuration. - Not UI-agnostic — the fork ships Blade views, routes and controllers that register themselves into the host app. Here, the core exposes services, events and artisan commands only; HTTP/UI integrations are optional and opt-in.
- Logic spread across helpers, gate callbacks and app models — consuming apps
had to re-implement
hasFeatureFlag()/featureFlags()themselves. Here, one service holds all evaluation logic and a trait provides the model sugar. - Legacy codebase — PHP 7.x era code. This package targets PHP 8.3+ and Laravel 12+, is analysed at PHPStan level 10 (max) and tested with Pest 4 via Orchestra Testbench.
Design goals
- UI-agnostic core — services + repository + events + artisan. Nothing else.
- Fully operable from the console — every CRUD/toggle/export operation has a non-interactive command form (usable in deploy scripts and CI), with interactive prompts when running in a TTY.
- Configurable scopes — you decide which entities a flag can target and by which attribute they are matched.
- DB-table compatibility with
trinetus/laravel-feature-flag— thefeature_flagstable (schema + stored values) is shared, so an app running the fork can switch without touching data. Compatibility ends there: API, behavior and integrations follow this package's design, not the fork's. - Thin-UI ready — stable service API, plain array return values and domain events make it trivial to bolt on Livewire/REST/Inertia admin screens.
Database compatibility with trinetus/laravel-feature-flag
Scope of compatibility: the DB table only — schema and stored values. Nothing else (classes, method names, gate abilities, routes, behavior details) is kept compatible; where the fork and this package's design differ, this package's design wins.
The table schema is identical to the fork, so both packages read/write the same table:
| Column | Fork | This package |
|---|---|---|
| table name | feature_flags | feature_flags |
id | increments | increments |
key | string | string (+ unique index on fresh installs) |
variants | text (JSON) | text (JSON) |
| timestamps | none | none |
Stored value semantics are preserved as well:
"on" // enabled for everyone
"off" // disabled for everyone
{"users": ["a@b.com"], "teams": ["T123"]} // enabled for listed identifiers
How compatibility is implemented:
- The package ships exactly one migration and never touches any table other
than
feature_flags. (The extra tables underworkbench/exist only for this repo's own dev/test playground — they are never loaded into, nor published to, a consuming application.) - The packaged migration is guarded by
Schema::hasTable('feature_flags')— on an existing fork database it is a no-op (covered by tests). - The
FeatureFlagmodel uses$timestamps = falseand castsvariantsto array. Variants::fromRaw()tolerates every shape the fork ever stored ("on"/"off"strings — even quoted or oddly cased — booleans, scope maps, scalar identifiers) and normalizes unknown junk tooffinstead of throwing.- String identifier matching is case-insensitive (design decision of this package; it also happens to keep fork-era e-mail lists working).
- Scope names are arbitrary, so existing data keeps working: legacy flags
targeting
teamssimply map onto whichever Eloquent model plays that role in your app —'teams' => ['model' => Team::class, 'identifier' => 'code']— no data migration needed. Renaming the scope later is an optional one-off data migration in the consuming app.
Migration path from the fork (consuming app)
The database stays as-is; the app code must be rewritten against the new API — no fork class, interface or ability has a compatibility shim:
composer remove trinetus/laravel-feature-flag && composer require trinetus/feature-flags- Publish + fill
config/feature-flags.php(map your scopes, see below). - Replace fork-era code (
FeatureFlagsEnablermappings,Feature::isEnabled()calls, app-levelhasFeatureFlag()/featureFlags()helpers, published views/routes) with this package's service calls and theHasFeatureFlagtrait. - Optionally enable the
gate/middleware/bladeintegrations.
Installation
Requires PHP 8.3+ and Laravel 12 or 13.
composer require trinetus/feature-flags
Migrate the database —
feature_flagsis the only table this package ever creates, and the migration is skipped when the table already exists:php artisan migratePublish the configuration:
php artisan vendor:publish --tag=feature-flags-config
Configuration
return [
// Which entities can a flag target, and how are stored values matched.
// The scope name is free-form and is the key used inside the stored
// variants, e.g. {"teams": ["T123"]}.
'scopes' => [
// shorthand — matched by the model's primary key
'users' => \App\Models\User::class,
// full form — matched by a specific attribute (must be a real DB column
// if you want to use the withFeatureFlag()/withoutFeatureFlag() query scopes)
'teams' => [
'model' => \App\Models\Team::class,
'identifier' => 'code',
],
],
// The full flag set is cached and invalidated automatically on every write
// (repository, commands, direct model writes).
'cache' => [
'enabled' => env('FEATURE_FLAGS_CACHE', true),
'store' => env('FEATURE_FLAGS_CACHE_STORE'), // null = default store
'key' => 'feature-flags:all',
'ttl' => null, // null = forever
],
// Optional framework integrations — all disabled by default.
'integrations' => [
'gate' => false, // @can('feature-flag', 'my-feature')
'gate_ability' => 'feature-flag',
'middleware' => false, // ->middleware('feature-flag:my-feature')
'middleware_alias' => 'feature-flag',
'middleware_status' => 404, // response when the flag is disabled
'blade' => false, // @feature('my-feature') … @endfeature
],
// Flags guaranteed to exist after `php artisan ff:sync` (deploy-friendly).
// Existing flags are never overwritten; deletion requires --prune.
'sync' => [
// 'my-feature' => 'off',
// 'beta-dashboard' => ['teams' => ['T123', 'T456']],
],
];
Usage
Service — the single source of truth
use Trinetus\FeatureFlags\Services\FeatureFlagsService;
use Trinetus\FeatureFlags\Facades\FF;
$ff = app(FeatureFlagsService::class); // or use the FF facade statically
// Global checks — true only when the flag is "on" for everyone
$ff->active('my-feature'); // bool
$ff->activeAny(['my-feature', 'other-feature']); // bool (x OR y)
$ff->activeAll(['my-feature', 'other-feature']); // bool (x AND y)
// Per-entity checks — global "on" also passes, targeting is matched otherwise
$ff->enabledFor($team, 'my-feature'); // bool
$ff->enabledForAny($user, ['a-feature', 'b-feature']);
$ff->enabledForAll($user, ['a-feature', 'b-feature']);
$ff->enabledFlagsFor($user); // list<string>
FF::active('my-feature'); // same API via facade
Unknown flags evaluate as inactive. A targeted check for a model that resolves
to no scope throws UnknownFeatureFlagScope — a misconfiguration should scream
in development (the optional gate integration catches it and returns false).
Model trait
class Team extends Model
{
use \Trinetus\FeatureFlags\Traits\HasFeatureFlag;
}
$team->hasFeatureFlag('my-new-feature'); // bool
$team->hasAnyFeatureFlag(['my-feature', 'other-feature']); // bool (x OR y)
$team->hasAllFeatureFlags(['my-feature', 'other-feature']); // bool (x AND y)
$team->featureFlags(); // list<string> of enabled flags
Team::withFeatureFlag('my-feature')->get(); // instances with the flag enabled
Team::withoutFeatureFlag('my-feature')->get(); // instances without it
The query scopes constrain in SQL (whereIn on the identifier column), so they
require the identifier to be a real DB column and follow the database collation.
Scope resolution
config('feature-flags.scopes') is the canonical registry — the interactive
commands offer exactly these scopes. Resolution order for a model:
featureFlagScopeName()/featureFlagIdentifierValue()overrides on the model (provided by theIsFeatureFlagScopetrait, whichHasFeatureFlagalready composes — override them to customize),- the config registry (exact class match first, then
instanceof), - for models using the trait but missing from the config: the table name as scope and the primary key as identifier.
Hierarchy — inherited flags
A model may expose featureFlagParents(); a targeted check that fails for the
model itself then walks the parents (cycle-safe, any depth):
class User extends Authenticatable
{
use HasFeatureFlag;
public function featureFlagParents(): iterable
{
return $this->team === null ? [] : [$this->team];
}
}
$user->hasFeatureFlag('beta'); // true when enabled for the user OR their team
Console
Every command is fully drivable by options (deploy scripts, CI) and falls back to interactive prompts in a TTY.
| Command | Purpose |
|---|---|
ff:list (--json) | Table (or JSON) of all flags and their targeting |
ff:add {key?} | Create a flag — --state=on\|off or repeatable --scope=teams:T1,T2 |
ff:edit {key} | Edit a flag — --state=, --scope=, --rename= |
ff:delete {key} | Delete a flag (--force skips confirmation) |
ff:on {key} / ff:off {key} | Quick global toggle |
ff:grant {key} {scope} {id...} | Add identifiers to a scope list |
ff:revoke {key} {scope} {id...} | Remove identifiers from a scope list |
ff:export (--file=) | Dump all flags as JSON (stdout by default) |
ff:import {file?} | Import JSON — merges by key; --replace --force deletes the rest |
ff:sync | Ensure flags from config sync exist (--prune deletes rest) |
Optional integrations
All disabled by default — enabling them in the config registers:
// integrations.gate — authorization checks (works for guests via the global state)
Gate::allows('feature-flag', 'my-feature');
@can('feature-flag', 'my-feature') … @endcan
// integrations.middleware — route gating (checks the authenticated user when scoped)
Route::get('/beta', BetaController::class)->middleware('feature-flag:my-feature');
// integrations.blade — template condition (optionally for a specific model)
@feature('my-feature') … @endfeature
@feature('my-feature', $team) … @endfeature
With everything disabled the package registers no gate ability, no middleware alias and no Blade directive (covered by tests).
Events
The repository dispatches domain events on every write — the extension point for audit logs, broadcasts or cache layers of a custom UI:
Trinetus\FeatureFlags\Events\FeatureFlagCreatedTrinetus\FeatureFlags\Events\FeatureFlagUpdatedTrinetus\FeatureFlags\Events\FeatureFlagDeleted
Each carries the affected FeatureFlag model in its public $flag property.
Building a thin UI on top
The package intentionally ships no UI, but everything a UI needs is public:
FeatureFlagRepository (cached CRUD), FeatureFlagsService (evaluation) and
the events above. A REST layer for a React/Vue admin is a few lines:
Route::middleware('can:manage-feature-flags')->group(function () {
Route::get('feature-flags', fn (FeatureFlagRepository $flags) => $flags->all());
Route::put('feature-flags/{key}', function (Request $request, FeatureFlagRepository $flags, string $key) {
$data = $request->validate(['variants' => 'required']);
return $flags->upsert($key, $data['variants']);
});
Route::delete('feature-flags/{key}', fn (FeatureFlagRepository $flags, string $key) => response()->json(['deleted' => $flags->delete($key)]));
});
A Livewire admin table is the same calls behind component actions; listen to the events to refresh other sessions.
License
MIT License