Skip to content

Latest commit

 

History

History
957 lines (701 loc) · 32.7 KB

pennant.md

File metadata and controls

957 lines (701 loc) · 32.7 KB

Laravel Pennant

Introduction

Laravel Pennant is a simple and lightweight feature flag package - without the cruft. Feature flags enable you to incrementally roll out new application features with confidence, A/B test new interface designs, compliment a trunk-based development strategy, and much more.

Installation

First, install Pennant into your project using the Composer package manager:

composer require laravel/pennant

Next, you should publish the Pennant configuration and migration files using the vendor:publish Artisan command:

php artisan vendor:publish --provider="Laravel\Pennant\PennantServiceProvider"

Finally, you should run your application's database migrations. This will create a features table that Pennant uses to power its database driver:

php artisan migrate

Configuration

After publishing Pennant's assets, its configuration file will be located at config/pennant.php. This configuration file allows you to specify the default storage mechanism that will be used by Pennant to store resolved feature flag values.

Pennant includes support for storing resolved feature flag values in an in-memory array via the array driver. Or, Pennant can store resolved feature flag values persistently in a relational database via the database driver, which is the default storage mechanism used by Pennant.

Defining Features

To define a feature, you may use the define method offered by the Feature facade. You will need to provide a name for the feature, as well as a closure that will be invoked to resolve the feature's initial value.

Typically, features are defined in a service provider using the Feature facade. The closure will receive the "scope" for the feature check. Most commonly, the scope is the currently authenticated user. In this example, we will define a feature for incrementally rolling out a new API to our application's users:

<?php

namespace App\Providers;

use App\Models\User;
use Illuminate\Support\Lottery;
use Illuminate\Support\ServiceProvider;
use Laravel\Pennant\Feature;

class AppServiceProvider extends ServiceProvider
{
    /**
     * Bootstrap any application services.
     */
    public function boot(): void
    {
        Feature::define('new-api', fn (User $user) => match (true) {
            $user->isInternalTeamMember() => true,
            $user->isHighTrafficCustomer() => false,
            default => Lottery::odds(1 / 100),
        });
    }
}

As you can see, we have the following rules for our feature:

  • All internal team members should be using the new API.
  • Any high traffic customers should not be using the new API.
  • Otherwise, the feature should be randomly assigned to users with a 1 in 100 chance of being active.

The first time the new-api feature is checked for a given user, the result of the closure will be stored by the storage driver. The next time the feature is checked against the same user, the value will be retrieved from storage and the closure will not be invoked.

For convenience, if a feature definition only returns a lottery, you may omit the closure completely:

Feature::define('site-redesign', Lottery::odds(1, 1000));

Class Based Features

Pennant also allows you to define class based features. Unlike closure based feature definitions, there is no need to register a class based feature in a service provider. To create a class based feature, you may invoke the pennant:feature Artisan command. By default the feature class will be placed in your application's app/Features directory:

php artisan pennant:feature NewApi

When writing a feature class, you only need to define a resolve method, which will be invoked to resolve the feature's initial value for a given scope. Again, the scope will typically be the currently authenticated user:

<?php

namespace App\Features;

use Illuminate\Support\Lottery;

class NewApi
{
    /**
     * Resolve the feature's initial value.
     */
    public function resolve(User $user): mixed
    {
        return match (true) {
            $user->isInternalTeamMember() => true,
            $user->isHighTrafficCustomer() => false,
            default => Lottery::odds(1 / 100),
        };
    }
}

Note Feature classes are resolved via the container, so you may inject dependencies into the feature class's constructor when needed.

Checking Features

To determine if a feature is active, you may use the active method on the Feature facade. By default, features are checked against the currently authenticated user:

<?php

namespace App\Http\Controllers;

use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Laravel\Pennant\Feature;

class PodcastController
{
    /**
     * Display a listing of the resource.
     */
    public function index(Request $request): Response
    {
        return Feature::active('new-api')
                ? $this->resolveNewApiResponse($request)
                : $this->resolveLegacyApiResponse($request);
    }

    // ...
}

Although features are checked against the currently authenticated user by default, you may easily check the feature against another user or scope. To accomplish this, use the for method offered by the Feature facade:

return Feature::for($user)->active('new-api')
        ? $this->resolveNewApiResponse($request)
        : $this->resolveLegacyApiResponse($request);

Pennant also offers some additional convenience methods that may prove useful when determining if a feature is active or not:

// Determine if all of the given features are active...
Feature::allAreActive(['new-api', 'site-redesign']);

// Determine if any of the given features are active...
Feature::someAreActive(['new-api', 'site-redesign']);

// Determine if a feature is inactive...
Feature::inactive('new-api');

// Determine if all of the given features are inactive...
Feature::allAreInactive(['new-api', 'site-redesign']);

// Determine if any of the given features are inactive...
Feature::someAreInactive(['new-api', 'site-redesign']);

Note When using Pennant outside of an HTTP context, such as in an Artisan command or a queued job, you should typically explicitly specify the feature's scope. Alternatively, you may define a default scope that accounts for both authenticated HTTP contexts and unauthenticated contexts.

Checking Class Based Features

For class based features, you should provide the class name when checking the feature:

<?php

namespace App\Http\Controllers;

use App\Features\NewApi;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Laravel\Pennant\Feature;

class PodcastController
{
    /**
     * Display a listing of the resource.
     */
    public function index(Request $request): Response
    {
        return Feature::active(NewApi::class)
                ? $this->resolveNewApiResponse($request)
                : $this->resolveLegacyApiResponse($request);
    }

    // ...
}

Conditional Execution

The when method may be used to fluently execute a given closure if a feature is active. Additionally, a second closure may be provided and will be executed if the feature is inactive:

<?php

namespace App\Http\Controllers;

use App\Features\NewApi;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Laravel\Pennant\Feature;

class PodcastController
{
    /**
     * Display a listing of the resource.
     */
    public function index(Request $request): Response
    {
        return Feature::when(NewApi::class,
            fn () => $this->resolveNewApiResponse($request),
            fn () => $this->resolveLegacyApiResponse($request),
        );
    }

    // ...
}

The unless method serves as the inverse of the when method, executing the first closure if the feature is inactive:

return Feature::unless(NewApi::class,
    fn () => $this->resolveLegacyApiResponse($request),
    fn () => $this->resolveNewApiResponse($request),
);

The HasFeatures Trait

Pennant's HasFeatures trait may be added to your application's User model (or any other model that has features) to provide a fluent, convenient way to check features directly from the model:

<?php

namespace App\Models;

use Illuminate\Foundation\Auth\User as Authenticatable;
use Laravel\Pennant\Concerns\HasFeatures;

class User extends Authenticatable
{
    use HasFeatures;

    // ...
}

Once the trait has been added to your model, you may easily check features by invoking the features method:

if ($user->features()->active('new-api')) {
    // ...
}

Of course, the features method provides access to many other convenient methods for interacting with features:

// Values...
$value = $user->features()->value('purchase-button')
$values = $user->features()->values(['new-api', 'purchase-button']);

// State...
$user->features()->active('new-api');
$user->features()->allAreActive(['new-api', 'server-api']);
$user->features()->someAreActive(['new-api', 'server-api']);

$user->features()->inactive('new-api');
$user->features()->allAreInactive(['new-api', 'server-api']);
$user->features()->someAreInactive(['new-api', 'server-api']);

// Conditional execution...
$user->features()->when('new-api',
    fn () => /* ... */,
    fn () => /* ... */,
);

$user->features()->unless('new-api',
    fn () => /* ... */,
    fn () => /* ... */,
);

Blade Directive

To make checking features in Blade a seamless experience, Pennant offers a @feature directive:

@feature('site-redesign')
    <!-- 'site-redesign' is active -->
@else
    <!-- 'site-redesign' is inactive -->
@endfeature

Middleware

Pennant also includes a middleware that may be used to verify the currently authenticated user has access to a feature before a route is even invoked. To get started, you should add a middleware alias for the EnsureFeaturesAreActive middleware to your application's app/Http/Kernel.php file:

use Laravel\Pennant\Middleware\EnsureFeaturesAreActive;

protected $middlewareAliases = [
    // ...
    'features' => EnsureFeaturesAreActive::class,
];

Next, you may assign the middleware to a route and specify the features that are required to access the route. If any of the specified features are inactive for the currently authenticated user, a 400 Bad Request HTTP response will be returned by the route. Multiple features may be specified using a comma-delimited list:

Route::get('/api/servers', function () {
    // ...
})->middleware(['features:new-api,servers-api']);

Customizing The Response

If you would like to customize the response that is returned by the middleware when one of the listed features is inactive, you may use the whenInactive method provided by the EnsureFeaturesAreActive middleware. Typically, this method should be invoked within the boot method of one of your application's service providers:

use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Laravel\Pennant\Middleware\EnsureFeaturesAreActive;

/**
 * Bootstrap any application services.
 */
public function boot(): void
{
    EnsureFeaturesAreActive::whenInactive(
        function (Request $request, array $features) {
            return new Response(status: 403);
        }
    );

    // ...
}

In-Memory Cache

When checking a feature, Pennant will create an in-memory cache of the result. If you are using the database driver, this means that re-checking the same feature flag within a single request will not trigger additional database queries. This also ensures that the feature has a consistent result for the duration of the request.

If you need to manually flush the in-memory cache, you may use the flushCache method offered by the Feature facade:

Feature::flushCache();

Scope

Specifying The Scope

As discussed, features are typically checked against the currently authenticated user. However, this may not always suit your needs. Therefore, it is possible to specify the scope you would like to check a given feature against via the Feature facade's for method:

return Feature::for($user)->active('new-api')
        ? $this->resolveNewApiResponse($request)
        : $this->resolveLegacyApiResponse($request);

Of course, feature scopes are not limited to "users". Imagine you have built a new billing experience that you are rolling out to entire teams rather than individual users. Perhaps you would like the oldest teams to have a slower rollout than the newer teams. Your feature resolution closure might look something like the following:

use App\Models\Team;
use Carbon\Carbon;
use Illuminate\Support\Lottery;
use Laravel\Pennant\Feature;

Feature::define('billing-v2', function (Team $team) {
    if ($team->created_at->isAfter(new Carbon('1st Jan, 2023'))) {
        return true;
    }

    if ($team->created_at->isAfter(new Carbon('1st Jan, 2019'))) {
        return Lottery::odds(1 / 100);
    }

    return Lottery::odds(1 / 1000);
});

You will notice that the closure we have defined is not expecting a User, but is instead expecting a Team model. To determine if this feature is active for a user's team, you should pass the team to the for method offered by the Feature facade:

if (Feature::for($user->team)->active('billing-v2')) {
    return redirect()->to('/billing/v2');
}

// ...

Default Scope

It is also possible to customize the default scope Pennant uses to check features. For example, maybe all of your features are checked against the currently authenticated user's team instead of the user. Instead of having to call Feature::for($user->team) every time you check a feature, you may instead specify the team as the default scope. Typically, this should be done in one of your application's service providers:

<?php

namespace App\Providers;

use Illuminate\Support\Facades\Auth;
use Illuminate\Support\ServiceProvider;
use Laravel\Pennant\Feature;

class AppServiceProvider extends ServiceProvider
{
    /**
     * Bootstrap any application services.
     */
    public function boot(): void
    {
        Feature::resolveScopeUsing(fn ($driver) => Auth::user()?->team);

        // ...
    }
}

If no scope is explicitly provided via the for method, the feature check will now use the currently authenticated user's team as the default scope:

Feature::active('billing-v2');

// Is now equivalent to...

Feature::for($user->team)->active('billing-v2');

Nullable Scope

If the scope you provide when checking a feature is null and the feature's definition does not support null via a nullable type or by including null in a union type, Pennant will automatically return false as the feature's result value.

So, if the scope you are passing to a feature is potentially null and you want the feature's value resolver to be invoked, you should account for that in your feature's definition. A null scope may occur if you check a feature within an Artisan command, queued job, or unauthenticated route. Since there is usually not an authenticated user in these contexts, the default scope will be null.

If you do not always explicitly specify your feature scope then you should ensure the scope's type is "nullable" and handle the null scope value within your feature definition logic:

use App\Models\User;
use Illuminate\Support\Lottery;
use Laravel\Pennant\Feature;

Feature::define('new-api', fn (User $user) => match (true) {// [tl! remove]
Feature::define('new-api', fn (User|null $user) => match (true) {// [tl! add]
    $user === null => true,// [tl! add]
    $user->isInternalTeamMember() => true,
    $user->isHighTrafficCustomer() => false,
    default => Lottery::odds(1 / 100),
});

Identifying Scope

Pennant's built-in array and database storage drivers know how to properly store scope identifiers for all PHP data types as well as Eloquent models. However, if your application utilizes a third-party Pennant driver, that driver may not know how to properly store an identifier for an Eloquent model or other custom types in your application.

In light of this, Pennant allows you to format scope values for storage by implementing the FeatureScopeable contract on the objects in your application that are used as Pennant scopes.

For example, imagine you are using two different feature drivers in a single application: the built-in database driver and a third-party "Flag Rocket" driver. The "Flag Rocket" driver does not know how to properly store an Eloquent model. Instead, it requires a FlagRocketUser instance. By implementing the toFeatureIdentifier defined by the FeatureScopeable contract, we can customize the storable scope value provided to each driver used by our application:

<?php

namespace App\Models;

use FlagRocket\FlagRocketUser;
use Illuminate\Database\Eloquent\Model;
use Laravel\Pennant\Contracts\FeatureScopeable;

class User extends Model implements FeatureScopeable
{
    /**
     * Cast the object to a feature scope identifier for the given driver.
     */
    public function toFeatureIdentifier(string $driver): mixed
    {
        return match($driver) {
            'database' => $this,
            'flag-rocket' => FlagRocketUser::fromId($this->flag_rocket_id),
        };
    }
}

Rich Feature Values

Until now, we have primarily shown features as being in a binary state, meaning they are either "active" or "inactive", but Pennant also allows you to store rich values as well.

For example, imagine you are testing three new colors for the "Buy now" button of your application. Instead of returning true or false from the feature definition, you may instead return a string:

use Illuminate\Support\Arr;
use Laravel\Pennant\Feature;

Feature::define('purchase-button', fn (User $user) => Arr::random([
    'blue-sapphire',
    'seafoam-green',
    'tart-orange',
]));

You may retrieve the value of the purchase-button feature using the value method:

$color = Feature::value('purchase-button');

Pennant's included Blade directive also makes it easy to conditionally render content based on the current value of the feature:

@feature('purchase-button', 'blue-sapphire')
    <!-- 'blue-sapphire' is active -->
@elsefeature('purchase-button', 'seafoam-green')
    <!-- 'seafoam-green' is active -->
@elsefeature('purchase-button', 'tart-orange')
    <!-- 'tart-orange' is active -->
@endfeature

Note When using rich values, it is important to know that a feature is considered "active" when it has any value other than false.

When calling the conditional when method, the feature's rich value will be provided to the first closure:

Feature::when('purchase-button',
    fn ($color) => /* ... */,
    fn () => /* ... */,
);

Likewise, when calling the conditional unless method, the feature's rich value will be provided to the optional second closure:

Feature::unless('purchase-button',
    fn () => /* ... */,
    fn ($color) => /* ... */,
);

Retrieving Multiple Features

The values method allows the retrieval of multiple features for a given scope:

Feature::values(['billing-v2', 'purchase-button']);

// [
//     'billing-v2' => false,
//     'purchase-button' => 'blue-sapphire',
// ]

Or, you may use the all method to retrieve the values of all defined features for a given scope:

Feature::all();

// [
//     'billing-v2' => false,
//     'purchase-button' => 'blue-sapphire',
//     'site-redesign' => true,
// ]

However, class based features are dynamically registered and are not known by Pennant until they are explicitly checked. This means your application's class based features may not appear in the results returned by the all method if they have not already been checked during the current request.

If you would like to ensure that feature classes are always included when using the all method, you may use Pennant's feature discovery capabilities. To get started, invoke the discover method in one of your application's service providers:

<?php

namespace App\Providers;

use Illuminate\Support\ServiceProvider;
use Laravel\Pennant\Feature;

class AppServiceProvider extends ServiceProvider
{
    /**
     * Bootstrap any application services.
     */
    public function boot(): void
    {
        Feature::discover();

        // ...
    }
}

The discover method will register all of the feature classes in your application's app/Features directory. The all method will now include these classes in its results, regardless of whether they have been checked during the current request:

Feature::all();

// [
//     'App\Features\NewApi' => true,
//     'billing-v2' => false,
//     'purchase-button' => 'blue-sapphire',
//     'site-redesign' => true,
// ]

Eager Loading

Although Pennant keeps an in-memory cache of all resolved features for a single request, it is still possible to encounter performance issues. To alleviate this, Pennant offers the ability to eager load feature values.

To illustrate this, imagine that we are checking if a feature is active within a loop:

use Laravel\Pennant\Feature;

foreach ($users as $user) {
    if (Feature::for($user)->active('notifications-beta')) {
        $user->notify(new RegistrationSuccess);
    }
}

Assuming we are using the database driver, this code will execute a database query for every user in the loop - executing potentially hundreds of queries. However, using Pennant's load method, we can remove this potential performance bottleneck by eager loading the feature values for a collection of users or scopes:

Feature::for($users)->load(['notifications-beta']);

foreach ($users as $user) {
    if (Feature::for($user)->active('notifications-beta')) {
        $user->notify(new RegistrationSuccess);
    }
}

To load feature values only when they have not already been loaded, you may use the loadMissing method:

Feature::for($users)->loadMissing([
    'new-api',
    'purchase-button',
    'notifications-beta',
]);

Updating Values

When a feature's value is resolved for the first time, the underlying driver will store the result in storage. This is often necessary to ensure a consistent experience for your users across requests. However, at times, you may want to manually update the feature's stored value.

To accomplish this, you may use the activate and deactivate methods to toggle a feature "on" or "off":

use Laravel\Pennant\Feature;

// Activate the feature for the default scope...
Feature::activate('new-api');

// Deactivate the feature for the given scope...
Feature::for($user->team)->deactivate('billing-v2');

It is also possible to manually set a rich value for a feature by providing a second argument to the activate method:

Feature::activate('purchase-button', 'seafoam-green');

To instruct Pennant to forget the stored value for a feature, you may use the forget method. When the feature is checked again, Pennant will resolve the feature's value from its feature definition:

Feature::forget('purchase-button');

Bulk Updates

To update stored feature values in bulk, you may use the activateForEveryone and deactivateForEveryone methods.

For example, imagine you are now confident in the new-api feature's stability and have landed on the best 'purchase-button' color for your checkout flow - you can update the stored value for all users accordingly:

use Laravel\Pennant\Feature;

Feature::activateForEveryone('new-api');

Feature::activateForEveryone('purchase-button', 'seafoam-green');

Alternatively, you may deactivate the feature for all users:

Feature::deactivateForEveryone('new-api');

Note This will only update the resolved feature values that have been stored by Pennant's storage driver. You will also need to update the feature definition in your application.

Purging Features

Sometimes, it can be useful to purge an entire feature from storage. This is typically necessary if you have removed the feature from your application or you have made adjustments to the feature's definition that you would like to rollout to all users.

You may remove all stored values for a feature using the purge method:

// Purging a single feature...
Feature::purge('new-api');

// Purging multiple features...
Feature::purge(['new-api', 'purchase-button']);

If you would like to purge all features from storage, you may invoke the purge method without any arguments:

Feature::purge();

As it can be useful to purge features as part of your application's deployment pipeline, Pennant includes a pennant:purge Artisan command:

php artisan pennant:purge new-api

php artisan pennant:purge new-api purchase-button

Testing

When testing code that interacts with feature flags, the easiest way to control the feature flag's returned value in your tests is to simply re-define the feature. For example, imagine you have the following feature defined in one of your application's service provider:

use Illuminate\Support\Arr;
use Laravel\Pennant\Feature;

Feature::define('purchase-button', fn () => Arr::random([
    'blue-sapphire',
    'seafoam-green',
    'tart-orange',
]));

To modify the feature's returned value in your tests, you may re-define the feature at the beginning of the test. The following test will always pass, even though the Arr::random() implementation is still present in the service provider:

use Laravel\Pennant\Feature;

public function test_it_can_control_feature_values()
{
    Feature::define('purchase-button', 'seafoam-green');

    $this->assertSame('seafoam-green', Feature::value('purchase-button'));
}

The same approach may be used for class based features:

use App\Features\NewApi;
use Laravel\Pennant\Feature;

public function test_it_can_control_feature_values()
{
    Feature::define(NewApi::class, true);

    $this->assertTrue(Feature::value(NewApi::class));
}

If your feature is returning a Lottery instance, there are a handful of useful testing helpers available.

Store Configuration

You may configure the store that Pennant will use during testing by defining the PENNANT_STORE environment variable in your application's phpunit.xml file:

<?xml version="1.0" encoding="UTF-8"?>
<phpunit colors="true">
    <!-- ... -->
    <php>
        <env name="PENNANT_STORE" value="array"/>
        <!-- ... -->
    </php>
</phpunit>

Adding Custom Pennant Drivers

Implementing The Driver

If none of Pennant's existing storage drivers fit your application's needs, you may write your own storage driver. Your custom driver should implement the Laravel\Pennant\Contracts\Driver interface:

<?php

namespace App\Extensions;

use Laravel\Pennant\Contracts\Driver;

class RedisFeatureDriver implements Driver
{
    public function define(string $feature, callable $resolver): void {}
    public function defined(): array {}
    public function getAll(array $features): array {}
    public function get(string $feature, mixed $scope): mixed {}
    public function set(string $feature, mixed $scope, mixed $value): void {}
    public function setForAllScopes(string $feature, mixed $value): void {}
    public function delete(string $feature, mixed $scope): void {}
    public function purge(array|null $features): void {}
}

Now, we just need to implement each of these methods using a Redis connection. For an example of how to implement each of these methods, take a look at the Laravel\Pennant\Drivers\DatabaseDriver in the Pennant source code

Note Laravel does not ship with a directory to contain your extensions. You are free to place them anywhere you like. In this example, we have created an Extensions directory to house the RedisFeatureDriver.

Registering The Driver

Once your driver has been implemented, you are ready to register it with Laravel. To add additional drivers to Pennant, you may use the extend method provided by the Feature facade. You should call the extend method from the boot method of one of your application's service provider:

<?php

namespace App\Providers;

use App\Extensions\RedisFeatureDriver;
use Illuminate\Contracts\Foundation\Application;
use Illuminate\Support\ServiceProvider;
use Laravel\Pennant\Feature;

class AppServiceProvider extends ServiceProvider
{
    /**
     * Register any application services.
     */
    public function register(): void
    {
        // ...
    }

    /**
     * Bootstrap any application services.
     */
    public function boot(): void
    {
        Feature::extend('redis', function (Application $app) {
            return new RedisFeatureDriver($app->make('redis'), $app->make('events'), []);
        });
    }
}

Once the driver has been registered, you may use the redis driver in your application's config/pennant.php configuration file:

'stores' => [

    'redis' => [
        'driver' => 'redis',
        'connection' => null,
    ],

    // ...

],

Events

Pennant dispatches a variety of events that can be useful when tracking feature flags throughout your application.

Laravel\Pennant\Events\RetrievingKnownFeature

This event is dispatched the first time a known feature is retrieved during a request for a specific scope. This event can be useful to create and track metrics against the feature flags that are being used throughout your application.

Laravel\Pennant\Events\RetrievingUnknownFeature

This event is dispatched the first time an unknown feature is retrieved during a request for a specific scope. This event can be useful if you have intended to remove a feature flag, but may have accidentally left some stray references to it throughout your application.

For example, you may find it useful to listen for this event and report or throw an exception when it occurs:

<?php

namespace App\Providers;

use Illuminate\Foundation\Support\Providers\EventServiceProvider as ServiceProvider;
use Illuminate\Support\Facades\Event;
use Laravel\Pennant\Events\RetrievingUnknownFeature;

class EventServiceProvider extends ServiceProvider
{
    /**
     * Register any other events for your application.
     */
    public function boot(): void
    {
        Event::listen(function (RetrievingUnknownFeature $event) {
            report("Resolving unknown feature [{$event->feature}].");
        });
    }
}

Laravel\Pennant\Events\DynamicallyDefiningFeature

This event is dispatched when a class based feature is being dynamically checked for the first time during a request.