Published May 5, 2023

Last Updated May 5, 2023

How to make Laravel Pennant features that apply globally

Categories: Laravel

Tags: #Feature Flags , #Laravel Pennant

If you use feature flags, Laravel Pennant probably caught your attention in the Laravel 10 announcement. This was the part of upgrading to Laravel 10 that I was looking forward to the most.

What is Laravel Pennant?

Pennant is a package that provides standardized feature flags out of the box. Features are saved in a dedicated table features, along with their scope(s) and status.

Here’s a simple example of how to define a Pennant feature:

<?php
Feature::define('new-feature', fn (User $user) => match (true) {
  $user->isBetaTester() => true,
  default => false,
});

(This is a simpler version of a snippet from the Pennant docs.)

Now if you were to check ‘new-feature' like Feature::active('new-feature'), you might find an entry like this in the database:

id | name        | scope             | value | created_at          | updated_at
 1 | new-feature | App\Models\User:1 | true  | 2023-01-01 00:00:00 | 2023-01-01 00:00:00

You can also define features in a service provider, or as dedicated feature classes.

<?php

namespace App\Features;

class NewFeature
{
    /**
      * Resolve the feature's initial value.
     */
    public function resolve(User $user): mixed
    {
        return match (true) {
            $user->isBetaTester() => true,
            default => false,
        };
    }
}

(This is a simpler version of a snippet from the Pennant docs.)

If you checked or updated a class-based feature, you might find an entry like this in the features table instead:

id | name                    | scope             | value | created_at          | updated_at
 1 | App\Features\NewFeature | App\Models\User:1 | true  | 2023-01-01 00:00:00 | 2023-01-01 00:00:00

How it works

The value returned in both examples is the default value. You decide the default based on whatever conditions you want. After it’s initialized, the table is checked instead.

As you can see, this example feature is based on a User class. Pennant’s doc explains that Pennant, by default, scopes features to the authenticated user.

This means if you check a feature like Feature::active('new-feature'), Pennant will automatically use a User object to check or act on a feature. If no matching entry for that feature and User scope is found in the DB, a new one will be saved based on the feature’s defaults.

Changing a feature’s scope

The Pennant docs give an example on how to change the scope. Basically, you type the parameter for default resolution to something else (say Team instead of User), and then use Feature::for() to pass the scope value.

If there were no default scope, you’d do Feature::for($user)->active('new-feature') or something similar every time.

So how do we make a feature global?

By “global”, I mean on or off for everyone.

You probably don’t want to do this too much on larger projects. But if you have a small public project, you’ll probably use it more.

The simplest way is to make your feature expect null.

<?php
// I use `false` as an example only. You can default your
// features to `true` (active) if you want.
Feature::define('new-feature', fn (null $scope) => false);

Then when you check features, you will pass null explicitly:

<?php
Feature::for(null)->active();

That technically resolves this question, but what if most of your features will be “global”?

Defaulting to “global scope”

Pennant’s docs have a section on setting the default scope. This is something you call in a service provider, like in the AppServiceProvider.

In our case, we can write something like this:

<?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) => null);
    }
}

Now anytime we don’t call Feature::for(), null will be passed automatically.

Now features will have entries like this in the DB:

id | name        | scope          | value | created_at          | updated_at
 1 | new-feature | __laravel_null | true  | 2023-01-01 00:00:00 | 2023-01-01 00:00:00

When null is used in the scope, Pennant saves __laravel_null as the scope.

Incidentally, if you were to test or update a feature using something like a command or queued job, this is also the scope. The docs aren’t explicit about this, but at the bottom of the “Checking Features” section of the doc, it implies this type of behavior is because it’s running in an unauthenticated context—meaning, there’s no auth’d user interacting with the application.

Summary

With a little digging, we were able to create features that apply globally instead of being scoped to specific contexts and even learn a little extra about Pennant that’s not spelled out in the docs.

I hope you found this post useful.

Thanks for reading!

Please confirm whether you would like to allow tracking cookies on this website, in accordance with its privacy policy.