francescomalatesta/laravel-feature
Laravel Feature helps manage feature flags in Laravel apps. Define features, toggle them globally or per user, and check them in code to enable or disable functionality without new deployments. Blade directives and middleware support gradual rollouts and experiments.
Install the package and publish its assets:
composer require francescomalatesta/laravel-feature
php artisan vendor:publish --provider="Francescomalatesta\LaravelFeature\LaravelFeatureServiceProvider"
php artisan migrate
Look at three places first:
config/feature.php — table name and default behaviour for unknown flags. This is the file you will actually touch when behaviour surprises you.features table with name and enabled. Everything the package does is just reads and writes against that table.Feature facade — the entire public surface lives there (isEnabled, enable, disable, and feature creation/removal helpers).First useful use case: wrap a risky new code path.
use Feature;
if (Feature::isEnabled('new-checkout')) {
return $this->newCheckout($request);
}
return $this->legacyCheckout($request);
Then toggle it without deploying code:
php artisan feature:enable new-checkout
php artisan feature:disable new-checkout
php artisan feature:list
And in Blade, for UI that should only render when a flag is on:
@feature('new-dashboard')
@include('dashboard.v2')
@endfeature
That is the whole package. Everything below is about using it without creating operational debt.
Check once per request, then pass the boolean down. A feature check is a DB read; looping over it is an N+1 you will not notice until it hurts.
class ReportController extends Controller
{
public function index(Request $request)
{
$v2 = Feature::isEnabled('reports.v2');
return $v2
? view('reports.v2', $this->data($request))
: view('reports.v1', $this->data($request));
}
}
If you need it in many layers, resolve it once in a middleware or service provider and bind it into the container as a small value object rather than calling the facade from everywhere.
Use constants or an enum for flag names. Typos on an unknown flag are silent (you get the default), which is the single most common source of “the flag is on but nothing changed.”
final class Flags
{
public const NEW_CHECKOUT = 'checkout.v2';
public const REPORTS_V2 = 'reports.v2';
}
Wrap the facade behind your own thin service. You get request-level memoization, a single place to add logging, and an easy migration path if you outgrow the package later.
class Flags
{
private array $cache = [];
public function on(string $name): bool
{
return $this->cache[$name] ??= Feature::isEnabled($name);
}
}
Seed flags as part of environment setup. Local machines and staging databases should not depend on a human running feature:enable after a fresh migrate:fresh. A seeder that asserts the expected baseline is cheap and removes a whole class of “works on my machine.”
// database/seeders/FeatureSeeder.php
Feature::create('checkout.v2', false);
Feature::enable('reports.v2');
Tie flags to deploy scripts, not to manual SSH sessions. In your release pipeline, after migrate:
php artisan feature:enable checkout.v2
This creates an audit trail in CI and means a rollback is an explicit step, not an act of memory.
Combine with gates for authorization, not as a substitute for it. A feature flag says “this code exists”; a gate says “this user may use it.” Keep them separate and compose them.
Gate::define('use-reports-v2', fn (User $user) =>
Feature::isEnabled(Flags::REPORTS_V2) && $user->isAdmin()
);
Roll out by percentage on top of the flag. The package has no percentage targeting. Combine the global flag with a stable per-user bucket so a user never flips back and forth.
public function onForUser(string $flag, User $user, int $percent): bool
{
if (! Feature::isEnabled($flag)) {
return false;
}
return (crc32($flag . ':' . $user->id) % 100) < $percent;
}
Test flags explicitly, in every test that depends on them. Never rely on leftover DB state. Set the flag in setUp() (or in the specific test), and disable it in tearDown() so a failure cannot poison the next test.
protected function setUp(): void
{
parent::setUp();
Feature::enable(Flags::NEW_CHECKOUT);
}
Use RefreshDatabase and, where possible, write two tests per flag: one with the flag on and one with it off. The “off” test is the one that actually protects rollback.
Remove the flag and the dead branch on a schedule. A flag that has been on for two releases is technical debt. Track creation dates in a README or a ticket, and delete the losing branch as part of the cleanup PR — not “later.”
Unknown flags resolve to the default, not to an error. If you mistype checkout.v2 as checkout-v2, you will not get an exception; you will get whatever config/feature.php specifies. When debugging “flag is on but code not taken,” check spelling and the exact row in the features table before anything else.
Enable/disable expects the row to exist. The artisan commands operate on an existing feature; creating and toggling are separate operations. In a pipeline, create the flag in a seeder or migration step before you try to enable it, otherwise the command fails and takes your deploy down with it.
There is a real DB read per check. No caching layer is provided out of the box. In a loop, a Blade partial included many times, or a queued job batch, hoist the check. If you need it, add a request-scoped cache in your own wrapper and flush it in tests.
Flags are global — there is no per-user or per-tenant storage. The table has one row per flag. Anything resembling targeting (per-plan, per-cohort, per-account) is your responsibility on top of the boolean. Do not try to encode targeting into flag names; you will end up with checkout.v2.enterprise.eu and regret it.
In tests, state leaks silently. Because checks are pure DB reads, a flag enabled in one test stays enabled for the next unless you reset it. Prefer a trait that disables all flags in tearDown(), or wrap the flag setup per test rather than in a base class shared across the suite.
Config changes need config:cache cleared. If you edit config/feature.php (table name, defaults) and see stale behaviour, run php artisan config:clear. In production this is almost always the cause of “the default changed but the app didn't.”
Table name must match the config. If you have multiple databases or a non-standard prefix, verify the migration and config/feature.php agree. A mismatch surfaces as a “table not found” query error pointing at the package's internals, which is easy to misread as a package bug.
Blade directive changes require recompiled views. If you add or change custom handling around the @feature directive, clear compiled views (php artisan view:clear). Otherwise you will be debugging the old compiled PHP.
Never put irreversible side effects behind a flag without a guard. Sending email, charging cards, dispatching jobs, writing to external systems — a flag flip mid-flight can duplicate work or strand records. Gate the read path with the flag; keep the write path unconditional or idempotent.
Enabling in production requires DB write access from your deploy environment. If your pipeline runs migrations with a restricted user, confirm the same credentials can write to features. A flag toggle is a data change, and treating it as a config change is how teams get surprised at 2am.
Staging and production diverge unless you seed them. Because the state lives in the database, not in code, a flag disabled in staging can be enabled in production and vice versa. Document the expected matrix, or better, drive it from your release script so it is reproducible.
Extension points are limited, so extend at the edges. The package is small and the surface is the facade. If you need caching, percentage rollouts, per-user targeting, or an admin UI, wrap the facade in your own service and build up — do not fork the package. For an admin panel, a simple resource controller against the features table plus Feature::enable() / Feature::disable() is enough, and it keeps the package as the single source of truth.
Watch Laravel version compatibility. This is an older, low-traffic package. On recent Laravel versions, confirm auto-discovery and the facade alias still register correctly; if the alias does not appear in config/app.php, add it manually and clear config:cache and bootstrap/cache.
How can I help you explore Laravel packages today?