henzeb / laravel-pennant-unleash
An Unleash driver for Laravel Pennant
Requires
- php: ^8.3
- laravel/pennant: ^1.0
- unleash/client: ^2.9.283
Requires (Dev)
- mockery/mockery: ^1.6
- orchestra/testbench: ^9.0|^11.0
- pestphp/pest: ^4.0
- pestphp/pest-plugin-laravel: ^4.0
- phpstan/phpstan: ^1.4
README
Laravel Pennant is a lightweight feature flag package, but its built-in drivers store state locally. Unleash is a feature management platform that evaluates flags server-side with rich targeting strategies.
This package bridges the two: it registers an unleash Pennant driver so you can use the full Pennant API while
Unleash handles all flag evaluation.
Table of contents
- Installation
- Configuration
- Context
- Variants
- Defining local defaults
- Development mode
- Testing this package
- Security
- Credits
- License
Installation
composer require henzeb/laravel-pennant-unleash
Publish the config file:
php artisan vendor:publish --tag=laravel-pennant-unleash
To use Unleash with Pennant, you must add an unleash entry in the stores
section of config/pennant.php, equivalent to adding this yourself:
'stores' => [ 'unleash' => [ 'driver' => 'unleash', ], // Pennant's built-in stores, e.g.: 'array' => [ 'driver' => 'array', ], ],
In your .env, add the following. Use your own credentials, of course.
UNLEASH_URL=https://your-unleash-instance/api UNLEASH_API_KEY=your-client-api-key UNLEASH_INSTANCE_ID=your-instance-id UNLEASH_APP_NAME=your-app-name # defaults to APP_NAME UNLEASH_CACHE_DRIVER=array # defaults to CACHE_DRIVER
Custom client builder
If you have specific needs for the client builder, you can use Feature::buildUnleashClientUsing() to customize the
UnleashBuilder.
use Unleash\Client\UnleashBuilder; Feature::buildUnleashClientUsing(function (UnleashBuilder $builder): UnleashBuilder { return $builder->withStrategy(new MyCustomStrategy()); });
Or return a completely new UnleashBuilder instance to bypass the package's own configuration:
use Unleash\Client\UnleashBuilder; Feature::buildUnleashClientUsing(function (UnleashBuilder $builder): UnleashBuilder { return UnleashBuilder::create() ->withAppUrl(config('unleash.app_url')) ->withInstanceId(config('unleash.instance_id')) ->withAppName(config('unleash.app_name')); });
Configuration
Caching
Toggle state fetched from Unleash is cached, and refreshed once the cache expires. Configure this in
config/unleash.php:
'cache' => [ 'driver' => env('UNLEASH_CACHE_DRIVER', env('CACHE_DRIVER')), 'ttl' => env('UNLEASH_CACHE_TTL', 15), // seconds 'stale_driver' => env('UNLEASH_STALE_CACHE_DRIVER'), 'stale_ttl' => env('UNLEASH_STALE_CACHE_TTL', 30 * 60), // seconds ],
stale_driver is optional and only used as a fallback when Unleash can't be reached and the main cache has already
expired; it defaults to the same store as driver. stale_ttl controls how long that stale cache remains
acceptable to serve.
Custom strategies
If you have custom activation strategies,
list their handler classes in config/unleash.php:
'strategies' => [ App\Unleash\Strategies\MyCustomStrategy::class, ],
Each class is resolved through Laravel's container, so constructor dependencies are injected automatically, and is added alongside the client's built-in strategy handlers.
Events
The underlying Unleash client fires its own events for things Pennant itself doesn't tell you about — a feature being queried that doesn't exist in Unleash, a feature evaluating as disabled, a strategy Unleash sent that no registered handler understands, a background fetch failing, or impression data being recorded. This package maps each of those to a plain Laravel event, so you can listen for them the normal way:
use Henzeb\Pennant\Unleash\Events\FeatureToggleNotFound; use Illuminate\Support\Facades\Event; Event::listen(function (FeatureToggleNotFound $event) { Log::warning("Feature [{$event->featureName}] was checked but does not exist in Unleash."); });
| Event | Fired when | Properties |
|---|---|---|
FeatureToggleNotFound |
A queried feature doesn't exist in Unleash | context, featureName |
FeatureToggleDisabled |
A feature exists but evaluated to disabled | feature, context |
FeatureToggleMissingStrategyHandler |
A feature has a strategy no registered handler supports | context, feature |
FetchingDataFailed |
The background fetch from the Unleash server failed | exception |
ImpressionDataReceived |
A feature with impression data enabled was evaluated | eventType, eventId, context, enabled, featureName, variant |
All classes live under Henzeb\Pennant\Unleash\Events. This is off by default; enable it in config/unleash.php:
'events' => env('UNLEASH_EVENTS_ENABLED', false),
Metrics reporting
By default, the client reports feature usage back to Unleash every 60 seconds, which drives the "last seen" and
usage metrics in the Unleash admin UI. Configure this in config/unleash.php:
'metrics' => [ 'enabled' => env('UNLEASH_METRICS_ENABLED', true), 'interval' => env('UNLEASH_METRICS_INTERVAL', 60_000), // milliseconds 'handler' => DefaultMetricsHandler::class, ],
handler must be a class implementing Unleash\Client\Metrics\MetricsHandler. It's resolved through Laravel's
container, so constructor dependencies are injected automatically. Swap it for your own class to replace the
client's default metrics handler.
Variant handler
Variant assignment (which variant a user falls into for a feature with variants configured) is delegated to a
handler. Configure it in config/unleash.php:
'variant_handler' => DefaultVariantHandler::class,
variant_handler must be a class implementing Unleash\Client\Variant\VariantHandler. It's resolved through
Laravel's container, so constructor dependencies are injected automatically. Swap it for your own class to
replace the client's default variant handler.
Context
The scope passed to Pennant is turned into an UnleashContext, so it can be matched by strategy constraints in
the Unleash admin UI.
Authenticated users
Pass a user (or anything implementing Illuminate\Contracts\Auth\Authenticatable) and the driver sends the auth
identifier as the Unleash currentUserId. In Unleash, target these with a userId constraint.
Feature::for($user)->active('my-feature');
Eloquent models
Pass an Eloquent model and the driver sends its class (or morph map alias, if configured) and key as custom context properties. In Unleash, target these with model and key constraints.
Feature::for($tenant)->active('my-feature');
For example, $tenant = App\Models\Tenant::find(42) sends:
[
'model' => 'App\Models\Tenant', // or the morph map alias, e.g. 'tenant'
'key' => '42',
]
so an Unleash strategy constraint on model with value App\Models\Tenant (or your morph map alias) combined
with a key constraint on 42 will match this scope.
Plain strings
Pass a plain string and the driver sends it as a custom context property. In Unleash, target these with a scope constraint.
Feature::for('some-identifier')->active('my-feature');
For example, Feature::for('some-identifier') sends:
[
'scope' => 'some-identifier',
]
so an Unleash strategy constraint on scope with value some-identifier will match this scope.
UnleashContext
Henzeb\Pennant\Unleash\Configuration\UnleashContext is a Pennant-aware context object you can construct and pass
as the scope directly, to set any Unleash context field (userId, sessionId, ipAddress, environment,
currentTime, custom properties) yourself.
use Henzeb\Pennant\Unleash\Configuration\UnleashContext; $context = new UnleashContext( currentUserId: '42', ipAddress: '1.2.3.4', sessionId: 'abc123', customContext: ['region' => 'eu-west'], ); Feature::for($context)->active('my-feature');
Custom properties can also be set in bulk, from an array or anything implementing
Illuminate\Contracts\Support\Arrayable, using setCustomProperties() (replacing them) or withCustomProperties()
(merging into the existing ones):
$context = UnleashContext::make()->withCustomProperties(['plan' => 'enterprise', 'region' => 'eu-west']);
It also supports Laravel's Conditionable trait:
$context = UnleashContext::make(currentUserId: '42') ->when($request->has('region'), fn($ctx) => $ctx->setCustomProperty('region', $request->region));
FeatureScopeable
Any object can become scopable by implementing Laravel\Pennant\Contracts\FeatureScopeable. Pennant calls
toFeatureIdentifier before passing the scope to the driver, so the driver receives whatever you return from it.
Return an UnleashContext (or any Unleash\Client\Configuration\Context) to have it used for flag evaluation.
This is the right approach when you have domain objects — such as a Tenant or Team — that you want to pass
directly as a scope without registering a global context resolver.
use Henzeb\Pennant\Unleash\Configuration\UnleashContext; use Laravel\Pennant\Contracts\FeatureScopeable; use Unleash\Client\Configuration\Context; class Tenant implements FeatureScopeable { public function toFeatureIdentifier(string $driver): mixed { return match ($driver) { 'unleash' => UnleashContext::make(customContext: ['tenantId' => (string) $this->id]), default => $this->id, }; } } Feature::for($tenant)->active('my-feature');
Custom context resolver
If you need to map an arbitrary scope to an Unleash context, register a resolver in a service provider:
use Henzeb\Pennant\Unleash\Configuration\UnleashContext; use Unleash\Client\Configuration\Context; Feature::resolveUnleashContextUsing(function (mixed $scope): ?Context { if ($scope instanceof Tenant) { return UnleashContext::make(customContext: ['tenantId' => $scope->id]); } return null; });
Returning null sends no context to Unleash.
Variants
Feature::value('my-feature') resolves the Unleash variant
for the given feature and scope:
- If the feature has no variants configured, it's evaluated as a plain boolean instead.
- If the feature (or the matched variant) is disabled, it returns
false. - If the variant has no payload, it returns the variant's name.
- If the variant has a
stringorcsvpayload, it returns the raw payload value as a string. - If the variant has a
jsonpayload, it returns the decoded value (array).
Feature::value('my-feature'); // false — feature/variant disabled // 'my-variant' — variant with no payload // 'hello' — variant with a string/csv payload // ['foo' => 'bar'] — variant with a json payload
Defining local defaults
Because this driver reads flags from Unleash, Feature::define() doesn't register an initial value the way it does
for Pennant's built-in drivers. Instead, it registers a default resolver that only runs when the feature
doesn't exist in Unleash yet — for example, before the toggle has been created there, or in an environment where
it hasn't been rolled out. Once the toggle exists in Unleash, the default is ignored and Unleash's evaluation is
used instead.
Without a default, a feature that doesn't exist in Unleash simply resolves to false. A default is only useful
when you need something other than that:
- a fail-open default (e.g.
true) for a toggle that should behave as enabled until it's deliberately created and configured in Unleash; - a non-boolean default, since without one an undefined feature resolves to the boolean
false, which won't match code expecting a string or decoded JSON payload fromFeature::value(); - an environment-specific default, e.g.
truelocally while every environment that matters defaults tofalsein production until someone enables the toggle there.
Feature::define('my-feature', fn (mixed $scope) => true);
Return a string or array instead of a bool and it's treated as a variant default, so Feature::value() gets
that string or array back instead of false. You can also return any object implementing Unleash's own Variant
interface directly — this package ships a fluent UnleashVariant for building one, which also uses Laravel's
Conditionable trait so you can use when()/unless() while building it:
use Henzeb\Pennant\Unleash\DTO\UnleashVariant; Feature::define( 'my-variant-feature', fn (mixed $scope) => UnleashVariant::make('trial') ->payload(['plan' => 'trial']) ->when($scope?->isAdmin(), fn (UnleashVariant $variant) => $variant->payload(['plan' => 'enterprise'])), );
Register this in a service provider's boot() method, same as any other Feature::define() call.
A feature that only has a local default (and doesn't exist in Unleash) won't show up in Feature::for($scope)->all()
— it's only used when the feature is checked directly, e.g. via Feature::active() or Feature::value().
Development mode
If you don't have an Unleash server available for local development at all, you may enable development mode instead, which makes the driver evaluate features from a local JSON file and never contact Unleash:
UNLEASH_DEVELOPMENT=true UNLEASH_BOOTSTRAP_FILE=/path/to/unleash-features.json
The file must contain feature definitions in the same shape Unleash's own client feature API returns them — this is passed directly to the underlying SDK's own bootstrap support, so strategies, constraints, and variants are evaluated exactly as they would be against a real server:
{
"features": [
{
"name": "my-feature",
"enabled": true,
"strategies": [
{
"name": "default",
"parameters": {},
"constraints": [
{ "contextName": "scope", "operator": "IN", "values": ["42"] }
]
}
]
}
]
}
While development mode is enabled, no app_url, api_key, or instance_id configuration is required. A feature
not present in the file still falls back to any resolver you registered with Feature::define(), exactly as it
would against a real server; if neither applies, it simply resolves to false.
If development mode is enabled without unleash.bootstrap_file configured, or the configured file doesn't exist,
an exception is thrown — this is a misconfiguration, not a state the driver silently works around.
Testing this package
composer test
Security
If you discover any security related issues, please email henzeberkheij@gmail.com instead of using the issue tracker.
Credits
License
The MIT License. Please see License File for more information.