stogon/unleash-bundle
Symfony bundle for integrating the Unleash feature flag service into your application. Define and manage feature toggles, run gradual rollouts, and target users with flexible activation strategies through a simple, configurable API.
Install and pin ^1.0.1. Earlier 1.0.0 builds shipped a broken container binding for FeatureRepository (an invalid named argument $httpClient) and injected the wrong HttpClientInterface implementation into UnleashHttpClient. On 1.0.0 the package can fail at container resolution time — typically the first time you resolve the repository or hit a feature check — so treat 1.0.1 as the real "first usable release" rather than an optional patch.
composer require <vendor>/laravel-unleash:^1.0.1
php artisan vendor:publish --tag=unleash-config
Then set the three values you actually need in .env:
UNLEASH_URL=https://unleash.example.com/api/
UNLEASH_API_TOKEN=default:development.xxxxxxxx
UNLEASH_ENVIRONMENT=local
Where to look first:
config/unleash.php — confirm the environment name, the API URL (it needs the trailing /api/), and whether caching is enabled. Most "it doesn't work" reports are a bad URL or a token scoped to the wrong environment.UnleashHttpClient or FeatureRepository, read the bindings before writing your own, and rebind after the package provider registers.php artisan about / php artisan tinker to confirm the config is actually loaded (config('unleash.environment')).First use case — gate a single route:
if (Unleash::isEnabled('new-checkout')) {
return view('checkout.v2');
}
return view('checkout.v1');
Verify it end-to-end by toggling the flag in the Unleash UI and reloading. If the value never changes, you are almost certainly looking at a cached response rather than a broken client — see the gotchas below.
Wrap the facade, don't scatter it. Raw Unleash::isEnabled('string-literal') calls are untyped and unsearchable. A thin domain wrapper pays for itself almost immediately:
final class Features
{
public const NEW_CHECKOUT = 'new-checkout';
public const AI_SUGGESTIONS = 'ai-suggestions';
public static function checkout(): bool
{
return Unleash::isEnabled(self::NEW_CHECKOUT);
}
}
This gives you one place to add context, logging, or a kill switch, and it makes flag cleanup a grep instead of an archaeology project.
Use context for stickiness, not for targeting logic you own. Pass user/session/tenant context so Unleash can make the rollout decision, rather than reimplementing percentage math:
Unleash::isEnabled('ai-suggestions', [
'userId' => (string) $user->id,
'sessionId' => session()->getId(),
'environment' => app()->environment(),
'properties' => ['plan' => $user->plan, 'region' => $user->region],
]);
Blade directives for view-layer flags. Keep presentation branches out of controllers:
@feature('new-checkout')
@include('checkout.v2')
@else
@include('checkout.v1')
@endfeature
Middleware for whole route groups. Guarding a group of routes is a better fit than checking inside every controller action — the flag becomes a deployment concern rather than a business rule. Register it in bootstrap/app.php (Laravel 11+) or Kernel.php, and be deliberate about ordering: put it after auth so user context is available, and before anything that triggers side effects.
Testing: never hit the network. The package resolves its client through the container, which is exactly the seam you want. In TestCase::setUp or a dedicated trait, swap the binding for a stub repository or a fake HTTP client so tests are deterministic and fast:
$this->app->bind(FeatureRepository::class, fn () => new InMemoryFeatureRepository([
'new-checkout' => true,
]));
If your tests are flaky or slow, the first thing to check is whether they are making real HTTP calls to Unleash — a container binding that bypasses the injected HttpClientInterface would defeat this, which is precisely the class of bug 1.0.1 fixed.
Background jobs and long-running workers. Feature state is read at check time. In a queue worker or Octane process that lives for hours, you will hold whatever the cache holds. If a flag must take effect "now" for a running worker, either shorten the TTL, resolve the repository fresh per job, or re-check inside the job rather than capturing the boolean at dispatch time.
Deployment pattern. Ship both code paths behind the flag, deploy with the flag off, enable for internal users, ramp, then remove the dead branch and the flag together. The flag is scaffolding — schedule its removal in the same PR that adds the winning path.
This release is a container-binding fix, so treat DI as the sharp edge. Two specific hazards:
$httpClient mismatch is the canonical example of how a rename in a constructor silently breaks a service definition that looked fine. If you publish or override the provider, prefer positional closures or fn (Container $app) => new FeatureRepository($app->make(HttpClientInterface::class)) over named-argument arrays.HttpClientInterface for your own implementation (Guzzle wrapper, retrying client, mock), re-verify it after upgrading. 1.0.1 corrected which implementation gets injected into UnleashHttpClient; if you were relying on 1.0.0's accidental behavior, your custom client may now be bypassed.Config caching bites hard. php artisan config:cache bakes UNLEASH_* values in. Changing .env without re-running config:cache gives you the old URL/token — and a confusing 401 or DNS error that looks like a package bug. Always php artisan config:clear when debugging connectivity.
Caching means stale flags. If the package caches responses, a flag you just toggled may not appear for the TTL window. Don't file a bug until you've waited out the TTL or flushed the cache. In local development, disable caching in config/unleash.php so toggles are instant.
Decide your failure mode on purpose. When Unleash is unreachable, you get whichever default the client chooses. For a kill switch guarding a database migration, "fail open" is a production incident; for a cosmetic UI experiment, "fail open" is correct. Don't let the package's default decide this for you — wrap calls where the blast radius is asymmetric, and log the fallback so you notice the outage.
Environment names must match exactly. A token issued for production used with UNLEASH_ENVIRONMENT=prod returns no flags, not an error. This is the second most common cause of "all my flags are off."
Check the URL shape. The base URL needs the /api/ path segment. Truncated URLs return HTML error pages that fail JSON decoding, surfacing as an opaque exception rather than a 404.
Token hygiene. Unleash API tokens are environment-scoped and often read-only; keep them in .env (never in config/unleash.php defaults) and rotate them like any other secret. Client-side tokens are intended for browser use — don't expose a server token through an endpoint that returns flag state.
Extension points. The FeatureRepository and HttpClientInterface bindings are the intended seams for custom caching, retry/backoff, or test doubles. Rebind them in a provider that registers after the package provider. Anything else — reaching into internals, extending the client class — will break on the next patch release, as 1.0.1 demonstrates.
Log the decisions, not just the errors. A single debug line emitted when a flag resolves is invaluable six months later when someone asks why a feature appeared for one tenant and not another. Keep it behind a config toggle so it can be switched on in production when you actually need it.
How can I help you explore Laravel packages today?