unleash/client
PHP client for Unleash, the open-source feature flag service. Evaluate toggles and activation strategies in PHP applications to support gradual rollouts, A/B tests, and kill switches, with framework-friendly integration for Laravel projects.
unleash/client is the framework-agnostic PHP SDK for Unleash, so a Laravel install needs three things wired up before the first flag check: the SDK itself, a PSR-18 HTTP client with PSR-17 factories, and a PSR-16 cache that survives between requests.
composer require unleash/client:^2.11 guzzlehttp/guzzle guzzlehttp/psr7 symfony/cache
Pin at least ^2.11 if you intend to use semver constraints: SEMVER_GTE and SEMVER_LTE were only added in v2.11.084, and older SDKs silently treat those operators as unsatisfied.
Add a config file (config/unleash.php) so nothing is hardcoded in the provider:
return [
'url' => env('UNLEASH_URL', 'https://unleash.example.com/api'),
'token' => env('UNLEASH_API_TOKEN'),
'app_name' => env('UNLEASH_APP_NAME', env('APP_NAME')),
'instance_id' => env('UNLEASH_INSTANCE_ID', gethostname()),
'environment' => env('UNLEASH_ENVIRONMENT', env('APP_ENV')),
];
Bind the client as a singleton in AppServiceProvider::register():
use Unleash\Client\Unleash;
use Unleash\Client\UnleashBuilder;
use Symfony\Component\Cache\Adapter\RedisAdapter;
use Symfony\Component\Cache\Psr16Cache;
$this->app->singleton(Unleash::class, function ($app) {
return UnleashBuilder::create()
->withAppName(config('unleash.app_name'))
->withInstanceId(config('unleash.instance_id'))
->withApiUrl(config('unleash.url'))
->withApiToken(config('unleash.token'))
->withHttpClient(new \GuzzleHttp\Client(['timeout' => 1.5]))
->withHttpClientRequestFactory(new \GuzzleHttp\Psr7\HttpFactory())
->withCache(new Psr16Cache(
new RedisAdapter(Redis::connection('cache')->client(), 'unleash', 30)
))
->build();
});
First useful case: protect a risky change behind a toggle instead of a deploy.
public function __construct(private Unleash $unleash) {}
public function checkout(Request $request)
{
if ($this->unleash->isEnabled('new-checkout-flow')) {
return $this->newCheckout($request);
}
return $this->legacyCheckout($request);
}
Keep the default you pass as the second argument (or the false return) aligned with safe behavior — a toggle check should never be the thing that breaks checkout when Unleash is unreachable.
Context-aware checks. Unleash evaluates strategies locally, so the context you pass decides the result. Build it from the request and inject it once:
use Unleash\Client\Configuration\Context;
$context = (new Context())
->setUserId((string) $request->user()?->id)
->setSessionId($request->session()->getId())
->setIpAddress($request->ip())
->addCustomProperty('tenant', (string) $request->user()?->tenant_id)
->addCustomProperty('appVersion', config('app.version'))
->setCurrentTime(now());
A middleware that stashes this in the container (or a ContextProvider bound in the container and passed via withContextProvider()) keeps controllers free of context assembly. For guest requests, still set sessionId — gradual rollout needs a stable identifier.
Version-gated rollouts with semver constraints (v2.11+). The SDK now evaluates the full semver operator set in strategy constraints, including SEMVER_GTE and SEMVER_LTE. That turns "keep API v1 alive for old mobile clients" into a flag instead of a version_compare() buried in a controller:
// Constraint in Unleash: appVersion SEMVER_GTE 4.2.0
// and a second strategy: appVersion SEMVER_LTE 4.2.x handled by your rollout plan
if ($this->unleash->isEnabled('mobile.new-payload-format', $context)) {
return $this->newPayload($data);
}
return $this->legacyPayload($data);
The custom property must carry a valid semver string (4.2.0, 4.2.0-rc.1). Feed it from your app version constant or the client's User-Agent, not from config('app.env')-style values.
Variants as remote configuration. Instead of a boolean plus a database row, use variant payloads for things like layout choice, model names, or timeout values:
$variant = $this->unleash->getVariant('search-provider', $context);
$driver = $variant->isEnabled()
? ($variant->getPayload()?->getValue() ?? 'algolia')
: 'database';
Blade and helper sugar. Register a small helper or Blade directive that pulls the container-bound context; avoid calling app(Unleash::class) from views directly so you can swap in a fake in tests.
Testing. Mock the interface in feature tests ($this->mock(Unleash::class)) and assert isEnabled was consulted rather than asserting on the real client. For unit tests of the resolution logic itself, build a client with an in-memory bootstrap provider (or an in-memory PSR-16 cache) so no network call happens. Never let the real client hit Unleash from the test suite — set registration and metrics off in the testing environment. If you test semver constraints, cover prerelease strings (1.0.0-beta.2 vs 1.0.0) explicitly; the SDK follows semver precedence, which surprises people who expect version_compare semantics.
Queues and console commands. Jobs and Artisan commands have no HTTP context. Build a context explicitly from the job payload (setUserId($this->userId)), and remember the singleton resolved in a queue worker is reused across jobs — don't stash a per-user context in a service that outlives the job.
Gradual rollout. For percentage rollouts, always supply a consistent identifier (userId for authenticated users, sessionId otherwise). Without one, the SDK has nothing stable to hash, and users flip on and off between requests.
Cache sharing. Point the PSR-16 cache at the same Redis instance your app already uses. This makes all web pods and queue workers share a single feature snapshot and avoids a cold fetch per PHP-FPM worker.
/api/client/features synchronously. Set a short Guzzle timeout (1–2s) and rely on the PSR-16 cache; otherwise the first request after each cache expiry pays the latency, and a Unleash outage becomes your outage.Illuminate\Contracts\Cache\Repository does not implement Psr\SimpleCache\CacheInterface. Either use symfony/cache's Psr16Cache directly or write a ~40-line adapter delegating get/set/delete to the Laravel repository. Skipping the cache entirely means a feature fetch on every request.SEMVER_GTE / SEMVER_LTE landed in v2.11.084. On an older SDK, a strategy using those operators simply never matches — no exception, no log line, just a flag that stays off. Audit composer.lock across services before rolling out a version-gated strategy, and verify with a throwaway flag that 1.2.3 matches SEMVER_GTE 1.2.0 on each pinned version.1.4.0+sha.abc123 compares equal to 1.4.0. Don't try to gate on a commit hash or build number embedded after +; use a dedicated numeric custom property (e.g. buildNumber with a numeric constraint) instead. Prereleases still sort below their release (2.0.0-rc.1 < 2.0.0).testing and local, or point at a local Unleash instance.userId/sessionId in the context. Log the context alongside decisions when debugging./api, and the token is a client token, not an admin/personal token. A 401 at boot throws rather than silently returning false — wrap the binding in a try/catch that falls back to a no-op implementation if you need hard availability.isEnabled('flag') with no fallback as "off when broken." If the feature is load-bearing (e.g., a new payment path), pass an explicit default that reflects the safe path, and consider a second flag for the fallback.checkout.new-flow), record the owning team in Unleash, and delete flags after rollout. An SDK check is cheap, but dead flags accumulate and mislead.v (v1.2.3) will not parse as semver and the constraint will fail to match.ContextProvider, custom bootstrap providers, and a decorator around the Unleash interface (for logging decisions or hard-coding overrides in tests) are the cleanest seams. Prefer a decorator over editing the service provider when you need per-request behavior.How can I help you explore Laravel packages today?