trinetus/feature-flags

Simple Feature Flags implementation for Laravel project.

Maintainers

Package info

gitlab.com/trinetus/feature-flags

Issues

pkg:composer/trinetus/feature-flags

Transparency log

Statistics

Installs: 541

Dependents: 0

Suggesters: 0

Stars: 1

v1.0.2 2026-08-31 08:29 UTC

This package is auto-updated.

Last update: 2026-08-31 06:30:34 UTC


README

Feature Flags

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:

  • globalon / off for 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), roles and teams. Every other domain entity (organization, tenant, branch, …) has to be squeezed into one of those names via getFieldValueForFeatureFlags() 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

  1. UI-agnostic core — services + repository + events + artisan. Nothing else.
  2. 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.
  3. Configurable scopesyou decide which entities a flag can target and by which attribute they are matched.
  4. DB-table compatibility with trinetus/laravel-feature-flag — the feature_flags table (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.
  5. 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:

ColumnForkThis package
table namefeature_flagsfeature_flags
idincrementsincrements
keystringstring (+ unique index on fresh installs)
variantstext (JSON)text (JSON)
timestampsnonenone

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 under workbench/ 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 FeatureFlag model uses $timestamps = false and casts variants to 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 to off instead 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 teams simply 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:

  1. composer remove trinetus/laravel-feature-flag && composer require trinetus/feature-flags
  2. Publish + fill config/feature-flags.php (map your scopes, see below).
  3. Replace fork-era code (FeatureFlagsEnabler mappings, Feature::isEnabled() calls, app-level hasFeatureFlag() / featureFlags() helpers, published views/routes) with this package's service calls and the HasFeatureFlag trait.
  4. Optionally enable the gate / middleware / blade integrations.

Installation

Requires PHP 8.3+ and Laravel 12 or 13.

composer require trinetus/feature-flags
  1. Migrate the database — feature_flags is the only table this package ever creates, and the migration is skipped when the table already exists:

     php artisan migrate
    
  2. Publish 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:

  1. featureFlagScopeName() / featureFlagIdentifierValue() overrides on the model (provided by the IsFeatureFlagScope trait, which HasFeatureFlag already composes — override them to customize),
  2. the config registry (exact class match first, then instanceof),
  3. 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.

CommandPurpose
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:syncEnsure 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\FeatureFlagCreated
  • Trinetus\FeatureFlags\Events\FeatureFlagUpdated
  • Trinetus\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