juststeveking/laravel-feature-flags
A lightweight, database-backed feature flag package for Laravel. Define flags, enable or disable them from code or Artisan commands, and gate routes, Blade views, and application logic through a clean facade. Simple to install and easy to drop into existing Laravel projects.
Install and wire up (5 minutes):
composer require juststeveking/laravel-feature-flags
php artisan vendor:publish --provider="JustSteveKing\FeatureFlags\FeatureFlagsServiceProvider"
php artisan migrate
Publish both the config file and the migration — if you only publish config you'll get a "base table or view not found" error the first time you evaluate a flag. Some providers expose separate publish tags for config vs. migrations; run php artisan vendor:publish interactively once and read the prompt rather than guessing the tag.
Where to look first, in this order:
config/feature-flags.php — this is where the bindable Feature model and the cache settings live. Two keys will drive most of your day-to-day decisions.src/Models/Feature.php — the schema in miniature. Read the $fillable/$casts before you write a seeder or a query.src/Facades/Feature.php and any helper file — confirm the actual method name (isActive(), active(), enabled() — verify against the README; the facade is a thin wrapper, so injecting the model directly is always an option if you dislike the naming).database/migrations/* — check the generated timestamp against your own migrations for collisions.Your first use case — ship something dark behind a flag. Create the flag, confirm it's off, then gate a route:
php artisan feature:create new-checkout --description="Owner: @you, expires: 2025-06-01"
php artisan feature:enable new-checkout # (or leave disabled to ship dark)
use JustSteveKing\FeatureFlags\Facades\Feature;
abort_unless(Feature::isActive('new-checkout'), 404);
Sanity check before you write any code: php artisan tinker → Feature::isActive('new-checkout') and verify it flips when you run feature:enable / feature:disable. If it doesn't flip, you've met the cache. Go to Gotchas.
Never use raw flag strings at call sites. Define constants once and grep for them when you retire the flag:
final class Features
{
public const NEW_CHECKOUT = 'billing.new-checkout';
public const BETA_DASHBOARD = 'dashboard.beta';
}
This is the single highest-leverage convention with this package, because an unknown flag name returns inactive rather than throwing. A typo in a controller is indistinguishable from "the feature is off," and you will lose an afternoon to it.
Gate at the entry point, not in the domain. Resolve the flag once — in the controller, the form request, or the middleware — and pass a boolean down:
public function store(StoreOrderRequest $request, OrderService $orders)
{
return $orders->handle(
$request->validated(),
useNewCheckout: Feature::isActive(Features::NEW_CHECKOUT),
);
}
Sprinkling facade calls through services and models makes the domain layer untestable without the package booted, and hides the branching from anyone reading the class. Two or three evaluation points per feature is the target.
Blade for presentation, never for authorization.
@feature(Features::BETA_DASHBOARD)
<a href="{{ route('dashboard.beta') }}">Try the new dashboard</a>
@endfeature
Hiding a link is not access control. A flag is globally flippable and has no relationship to who the user is — pair any flag-gated route with your normal can:/policy checks. Use 404 rather than 403 for the "flag off" case so an unreleased feature is indistinguishable from a nonexistent one.
Middleware for whole route groups. The package registers a feature alias; use it for beta sections and internal tooling:
Route::middleware('feature:'.Features::BETA_DASHBOARD)->group(function () {
// ...
});
This is the cheapest way to gate a chunk of routes, and it fails closed if the flag row is missing.
Queues: decide deliberately where you evaluate. Evaluating at dispatch time and passing the boolean into the payload freezes the flag state at the moment the user acted — the right choice for "this order was placed with new pricing." Evaluating inside the worker lets a flip affect in-flight jobs — the right choice for kill switches. Pick one on purpose; the default (evaluate somewhere deep inside the job) is a source of confusing repros.
Seeders are not optional. Flags are database rows, so a fresh CI environment or a new staging box starts with everything off. Add an idempotent seeder to the deploy pipeline, after migrate:
Feature::firstOrCreate(['name' => Features::NEW_CHECKOUT], [
'enabled' => false,
'description' => 'Owner: @you, expires: 2025-06-01',
]);
Without this, staging silently behaves as if the feature doesn't exist, and the "it works locally" / "it's missing in staging" bug reports start.
Tests. Refresh the database in setUp, create the flag in both states, and assert both paths for anything touching checkout, pricing, or auth:
Feature::create(['name' => Features::NEW_CHECKOUT, 'enabled' => true]);
The rollout ritual. Ship dark (flag off, merged to main) → enable in staging → smoke test → enable in prod → monitor → schedule removal. For destructive features the order is fixed: migrate schema → flip flag on → delete the old code path and the flag row together. Flipping before the migration ships is a guaranteed 500.
Admin surface: don't build one yet. php artisan feature:enable|disable on the box (or as a deploy/ops job) is sufficient for a modest flag inventory. If a UI becomes necessary it's a handful of lines on top of the model — but that's your build, and it comes with the authorization problem you now own.
tinker check whenever a feature "isn't there."config:cache, Octane, and long-running queue workers needs to be verified against your setup, not assumed. If you're on Octane or a worker fleet, confirm where invalidation happens on enable/disable before you promise anyone an instant kill switch — and clear the cache manually the first time you're surprised.config/feature-flags.php. When you change the bindable model, you need config:clear / a redeploy — a stale cached config pointing at the wrong model produces very confusing results.features table, resolve it before migrate rather than after.refund, impersonate, export-pii) on a flag. Flipping prod is effectively a deploy and should be permissioned like one — most teams get this wrong exactly once.enabled_by / enabled_at history. The model is bindable in config, so you can subclass it with audit columns and a scope or two — but budget that as a build, not a configuration toggle.description at creation time, review the flag list on a schedule, and delete the flag in the same PR that deletes the losing branch. Treat "flag exists after 90 days" as a bug.enabled is true? (3) name string matches exactly — case, dots, dashes? (4) cache cleared? (5) config cached with a stale model binding? (6) are you evaluating in a different process (queue worker, Octane worker) that has its own cache?composer.json against your runtime before you standardize, and confirm the caching behavior above with a branch deploy before rolling it across services.How can I help you explore Laravel packages today?