henzeb/laravel-pennant-unleash

An Unleash driver for Laravel Pennant

Maintainers

Package info

github.com/henzeb/laravel-pennant-unleash

pkg:composer/henzeb/laravel-pennant-unleash

Transparency log

Statistics

Installs: 0

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

v0.4-beta 2026-07-05 16:23 UTC

This package is auto-updated.

Last update: 2026-07-05 17:11:24 UTC


README

Build Status Latest Version on Packagist Total Downloads License

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

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 string or csv payload, it returns the raw payload value as a string.
  • If the variant has a json payload, 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 from Feature::value();
  • an environment-specific default, e.g. true locally while every environment that matters defaults to false in 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.