liberusoftware/feature-flags
Laravel feature flag package for toggling features at runtime. Define flags, enable or disable them per environment or user, and ship changes safely without redeploying. Works with Laravel's config and container, plus Blade and middleware helpers for gradual rollouts and A/B tests.
The package is a Liberu module, so treat it like the rest of that ecosystem: install it as a Composer dependency and let the module service provider register itself.
composer require liberusoftware/feature-flags
php artisan vendor:publish --tag=feature-flags-config
php artisan vendor:publish --tag=feature-flags-migrations
php artisan migrate
Because the package has no dependents and no stars, pin it explicitly in composer.json ("liberusoftware/feature-flags": "x.y.*") rather than floating on ^, and skim the published migration/config before running it in a shared database.
Where to look first, in order:
config/feature-flags.php — this is where the resolution order (environment → tenant → actor), cache TTL, telemetry toggle, and default fallback live. Read this before writing any call sites; most "why is this flag on?" questions are answered here.First use case: gate one new UI surface behind a tenant-scoped boolean flag.
if (Feature::for($tenant)->for($user)->active('billing.invoices.v2')) {
return view('billing.invoices.v2');
}
return view('billing.invoices.legacy');
Ship that, verify the legacy path still renders for tenants without the flag, then expand. Don't start by flagging ten things at once — the value of this package is the targeting and ownership metadata, and you only learn whether you're using it correctly by watching one flag move through its lifecycle.
Typed flags over stringly-typed booleans. The headline feature is typed flags. Use the type system instead of (bool) casting at the call site — a string flag for a variant name, an int for a limit, a json/array flag for structured config. This keeps flags from degenerating into a pile of true/false where nobody remembers what true meant. Cast once at the resolver boundary and let call sites receive the declared type:
$limit = Feature::for($tenant)->value('reports.max_rows'); // int, per flag type
Targeting precedence: environment → tenant → actor. The package resolves most-specific-wins. Write call sites that pass the full context, not just the piece you care about, so the resolver can apply the whole ladder:
Feature::for($tenant)->for($user)->active('checkout.one_click');
In jobs and console commands there is often no authenticated actor — pass the tenant explicitly from the job payload rather than relying on ambient auth state. A flag that silently falls back to "actor = null" in a queue worker is the most common source of "works in the browser, not in the queue" bugs.
Stable rollout via deterministic bucketing. Percentage rollouts must hash a stable identifier — user ID, tenant UUID, or a dedicated rollout_key — not a session ID, request ID, or email (which can change). Put the bucketing subject in one place so a user doesn't get bucketed differently on web vs. API vs. queue. Where the package supports per-flag rollout keys, use them for anything multi-tenant so a single large tenant doesn't skew your rollout percentages.
Ownership and expiry as first-class metadata. Every flag you create should carry an owner and an expires_at. This is the package's main differentiator over a config() boolean, and it only pays off if you enforce it:
expires_at.Telemetry boundaries. The package emits resolution telemetry, which is genuinely useful for answering "who is actually in the new path?" — but keep PII out of it. Log the flag name, the decision, and opaque identifiers (hashed actor/tenant keys) rather than raw emails. Sample aggressively in high-traffic paths; flag evaluation happens on every request and telemetry can easily become the most expensive part of a request. In local and test environments, disable telemetry emission entirely via config.
Testing. Override flag state in tests rather than mocking the facade globally. A per-test helper that enables a flag, runs the test, and resets state avoids the classic "test passes alone, fails in suite" ordering bug:
protected function setUp(): void
{
parent::setUp();
Feature::flushCache(); // or the package's equivalent reset
}
If the package offers an in-memory / array driver, use it for tests instead of hitting the database for every assertion.
Integration points worth wiring up early: a Blade directive for view gating, a middleware alias for route-level flags, and a validation rule or policy check for flags that gate permissions-adjacent behavior. Keep the flag check at the boundary (controller/middleware) rather than scattering it through domain services — it makes the eventual removal a single-file diff.
Zero stars and zero dependents is a real signal. Not a reason to avoid it, but a reason to read the source before depending on it in production, pin the version, and avoid relying on undocumented internals. If you build custom targeting rules, put them behind your own thin adapter class so a breaking change upstream is a one-file fix on your side.
Cache invalidation is the #1 operational failure mode. Flipping a flag in the admin UI does not necessarily bust the resolver cache. Check the configured TTL and whether the package exposes a flush method — call it from your flag-update flow. If you're on a multi-server setup without a shared cache store, expect inconsistency for the duration of the TTL.
Long-lived processes hold stale state. Octane workers, queue workers, and php artisan serve daemons keep their container alive across requests. If the resolver caches in-process, a flag change won't be seen until the worker recycles. Either use a short TTL, flush on the relevant events, or accept the delay and document it.
config:cache and env(). If the config file calls env() directly, your flags will silently read null once config is cached. Never call env() outside config files, and verify the published config follows that rule — it's a common module-publishing oversight.
Evaluation order surprises. When environment, tenant, and actor rules all match, "most specific wins" is not always intuitive — especially if an environment-level kill switch is expected to override a tenant-level enable. Confirm the precedence in the resolver source and write one integration test per level so the behavior is pinned in your suite, not just in the package's.
Expired flags don't delete themselves. An expires_at in the past is metadata, not enforcement. Decide explicitly whether expired means "off" or "still works but flagged as stale," and make sure that decision is the one you actually configured — it's easy to assume expiry disables the flag when it merely reports it.
Percentage rollouts drift if the bucketing key changes. Renaming a flag, changing the rollout key, or changing the salt re-buckets everyone. Treat the flag name and its rollout key as immutable once enabled; create a new flag instead of mutating an active one.
Telemetry cardinality. Per-flag events are cheap; per-actor-per-flag-per-request events are not. If you're emitting to a metrics backend, aggregate by flag name and decision and let the backend handle sampling, or you'll blow up your series count.
Extension points to prefer: custom targeting rules and a custom cache store are the two safe seams. Avoid subclassing the resolver itself unless the package documents it as stable — that's the class most likely to churn between minor versions.
When to reach for something else: if you only ever need a handful of boolean flags with no tenant targeting, owner metadata, or rollout percentages, a config file plus an env var will be simpler and has a smaller blast radius. This package earns its keep once flag governance (who owns it, when does it die, who is in the rollout) becomes the actual problem.
How can I help you explore Laravel packages today?