A guide to feature flags in Laravel using Laravel Pennant

Read about how to use Laravel Pennant to add feature flags to your Laravel apps. We'll show you use cases for feature flags, the advantages and disadvantages, and how to write tests for your feature flag code.

As a developer, you want to be able to release code early and often. It helps you to get feedback from users, and it helps you avoid having to deal with large, complex Git merges. However, it's not always so straightforward.

What happens if you want to test a new feature you've been working on with a subset of users? Alternatively, maybe you want to hide a feature that isn't quite ready for production or enable a feature for a specific user or team on a specific subscription level.

This is where feature flags come in. They allow you toggle features in your applications based on given conditions.

In this article, we'll discuss what feature flags are, as well as the advantages and disadvantages of using them. We'll also take a look at how to use feature flags in Laravel using the first-party Laravel Pennant package. We'll then delve into testing feature flags in your Laravel applications.

By the end of this article, you should have a good understanding of what feature flags are and feel confident enough to start using them in your own applications.

What are feature flags?

Feature flags, sometimes referred to as "switches" or "feature toggles", are a concept in the software development world that involves toggling features on or off based on certain conditions at runtime. Generally, they allow you to programmatically toggle a feature without needing to redeploy an application. This might be based on a user’s subscription level or toggled by a user in an admin panel.

To provide an example of what a feature flag is, let's say you're building a blogging platform and working on a new social-sharing feature. This feature will allow users to share their blog posts directly to their social media platforms after publishing them. Imagine that you want to test it with a subset of users before releasing it to everyone. You could use a feature flag to toggle the feature on or off for a specific user or group of users. This means the feature will only be available to those users, and everyone else will not see it. After a phase of initial testing, if you're happy with the stability of the feature, you can then release it to everyone.

The advantages of feature flags

To get a better understanding of what feature flags achieve, let's take a look at some of the use cases and advantages of using them.

Enabling features for specific users or organizations

Imagine that you have a large enterprise application that offers three different features: email, chat, and video conferencing. You could use a feature flag for each of these features which only allows the users or organizations to access the features if they're enabled.

For example, you may have one organization using your application that only wants access to your email feature. Using feature flags in your code, you could hide the chat and video conferencing features from them.

You might also want to add an admin panel that your sales team (or similar) can use to enable these features at a later date if the organization decides they want to use them.

Similarly, if you have a software-as-a-service (SaaS) application that offers different subscription levels, you could use feature flags to enable or disable features based on the subscription level of the user. This means that as a user upgrades their subscription, they will automatically gain access to the features available at that level.

A/B testing

Another use case for feature flags is A/B testing. This is where you test two different versions of a feature to see which one performs better. For example, you might want to test two different versions of a landing page to see which one converts better.

You could use a feature flag to determine which version of the page to show to the user. Using an A/B testing tool or analytics platform, you could then assess which version of the page is the more optimized version.

Complements trunk-based development

You'll likely be used to using Git Flow as your branching strategy when working on projects. Let's quickly recap the basics of Git Flow. This involves having a master (or sometimes called main) branch that contains all the production-ready code that has been released or is due to be released. There is also a develop branch, which tends to contain all the work in progress and will be implemented in the next release. There may also be a staging branch that contains the code currently in the staging environment, such as a separate server where the application can be tested before release. Typically, before each release, changes from the develop or staging branch (depending on whether a staging branch is being used) will be merged into a release branch (such as release/v1.1.0) that will then be merged into the master branch once the release is ready.

Git Flow places a focus on "feature branches". This is where you'll create a new Git branch off the develop (or similar) branch called something like feature/auth-system. The feature branches typically focus on adding a particular feature and are only merged once the feature is production-ready and the team is comfortable with releasing it. These feature branches can exist for weeks or months, meaning that the code being added to them can become stale and out of date. This can lead to merge conflicts when you try to merge the feature branch back into the develop branch, which can be time-consuming and frustrating to deal with. In cases of large merge conflicts, it may also lead to bugs being introduced into the codebase if the conflicts aren't dealt with correctly.

Another issue is that if you have two separate feature branches, you have no idea how the changes implemented in each one will affect the other until they've been merged into the develop branch. This can then lead to the developers needing to make more changes to the code to fix any issues that arise from the merge.

Another branching strategy that proposes a different approach to Git Flow is trunk-based development (TBD). TBD proposes that you should only have a single master branch that contains all the production-ready code. It emphasizes creating short-lived branches that are merged into the master branch as soon as possible. The branches don't necessarily need to include an entire feature. Instead, they can be used to implement a small part of a feature.

For example, let's say you're building a profile page your application. You might follow this type of workflow:

  • Create a branch from master.
  • Add a basic profile page that allows you to update your profile name.
  • Merge back into master.
  • Create a branch from master.
  • Add the functionality to the profile page to update your profile picture.
  • Merge back into master.
  • Create a branch from master.
  • Add functionality to the profile page to enable two-factor authentication.
  • Merge back into master.
  • And so on...

As you can see, the branches are focused and short-lived. However, because the code is constantly being merged back into the master branch, it might be constantly leading to new code being released to production, even if the feature isn't ready yet. This is where feature flags can really complement the TBD workflow.

You could create a feature flag for a profile page that is disabled by default. This means you can merge as much profile page-related code into the master branch as you want, but the feature won't be available to users until you're confident that the profile page feature is production-ready. This means you can keep your branches short-lived and avoid merge conflicts and avoid having to release code that isn't ready yet. As soon as the feature is complete, you can then enable the feature flag and release it to everyone.

It's worth noting that while TBD can be a good approach to take, it's not necessarily suitable for all projects. It's important to consider the pros and cons of each branching strategy and choose the one that's right for your project. It requires you to have a very good-quality test suite and continuous integration (CI) process in place to reduce the chances of introducing bugs into your codebase or accidentally releasing code that isn't ready yet without hiding it behind a feature flag.

The disadvantages of feature flags

Although feature flags can be useful, they also have some disadvantages. Let's take a look at some of them.

Add extra complexity to your code

One of the main disadvantages of using feature flags is that they can add extra complexity to your code. Here is an example:

if (Feature::active('telecommunications')) {
    // Telecommunications is enabled...
} else {
    // Telecommunications is disabled...
}

In this example, we create two branches in our code's flow, thereby increasing the complexity of the code. Even though it is only a simple "if" statement, it means that we must now write tests for both branches to ensure that they work as expected. This can be time-consuming but is necessary to reduce the likelihood of bugs being introduced into our code. For example, if we only wrote tests for the "Telecommunications is enabled" branch, we wouldn't know if the "Telecommunications is disabled" branch was working as expected, and we might accidentally run some code that should only be run if the feature is enabled.

Additionally, as the feature grows, it's likely that you'll end up with a lot of conditions like this scattered around your application. Therefore, it's important to be careful and avoid adding too many feature flags, or you will end up with a lot of extra code that is only used in certain circumstances. Remember, the more conditionals your code has, the more difficult it will be to test your code.

Remembering to remove the feature flags

Continuing with our telecommunications feature, let's imagine that you are using feature flags to hide a feature from users while you are working on it. Once the feature is finished, you may want to leave it in for a short period of time. By doing so, it means you'll be able to disable the feature if any major bugs or issues are detected and until you're confident that it's stable enough to be released to everyone.

After you're happy with the stability of the feature, you'll need to remember to delete the feature flag. This might include removing the feature flag definition, removing the feature flag check from your code, and removing any tests written for the feature flag. If you forget to remove the feature flag, you'll end up with a lot of dead code in your application that isn't being used.

Feature flags in Laravel

Now that we have an understanding of what feature flags are, let's take a look at how we can use them in Laravel.

One way to implement feature flags in Laravel is to use Laravel Pennant. Pennant is a first-party package created and maintained by the Laravel core team. It provides a simple way to define and check feature flags in Laravel applications and comes with several handy features out of the box.

Let's dive into the benefits of using Pennant and how we can use it in our applications.

Benefits of using Pennant

After working on many Laravel projects in the past, I've seen that each team has its own approach to implementing feature flags; some better than others. Pennant provides a standardized way of implementing feature flags, meaning that if you move between Laravel projects, you'll be able to quickly get up to speed with how feature flags are implemented if they're using Pennant.

Additionally, Pennant encourages us to abstract the logic of the feature flag away using a consistent approach. It allows us to check the value of the feature flag without needing to know how or where the value is determined. This means that we can change the implementation of the feature flag without having to make changes to our application code. To demonstrate this point, let's take a look at an example.

Imagine that we have a "telecommunications" feature in our application and want to disable it for all users. To do this, we may want to store the value in a config field and access it in our code:

if (config('features.telecommunications.enabled')) {
    // Telecommunications is enabled...
} else {
    // Telecommunications is disabled...
}

There may be code like this throughout your application, including in controllers, middleware, views, service classes, and so on. Let's say we wanted to update this functionality so that the feature flag is stored in the database instead of a config file, or we want to enable it for a specific user. We'd need to go through our application and update all the places where we're checking the value of the feature flag and change them to match the new logic. This can be a time-consuming and error-prone process.

Instead, we could abstract the logic of the feature flag away using Pennant (we'll take a look at how to do this later). We can then check the value of the feature flag:

if (Feature::active('telecommunications')) {
    // Telecommunications is enabled...
} else {
    // Telecommunications is disabled...
}

As a result, we'll be able to change the logic used in the feature flag definition in a single place without needing to update our application code. This makes it much easier to maintain and update.

Installing Laravel Pennant

To get started with using Pennant, we'll first need to install the package using Composer by running the following command in our Laravel project's root:

composer require laravel/pennant

Once the package has been installed, we'll need to publish the package's config file and database migration using the following command:

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

Running this command should create two new files:

  • config/pennant.php - The package's config file.
  • database/migrations/2022_11_01_000001_create_features_table.php - The package's database migration that will be used to create the features table in our database.

We can then run the database migration using the following command:

php artisan migrate

Your database should now have a new features table.

Drivers

Out of the box, Pennant supports two different drivers: array and database.

Both of these drivers are used to store the result after checking a feature flag. When a feature flag is first checked, the result will be calculated at runtime and then stored. This means that the next time the feature flag is checked, the result will be read from the storage driver instead of being calculated again. This helps to improve the performance of your application.

If you choose to use the array driver, the result will be stored in an in-memory array. This means that at the end of the request lifecycle, the result will be lost and will need to be recalculated on the next request.

If you choose to use the database driver, the result will be stored in the features table in your database. This means that the result will be persisted between requests in a centralized location. As a result, the database driver is ideal if your application is running on multiple servers or if you're using a serverless environment. Furthermore, if you're using the database driver, it reduces the need to calculate the result of the feature flag on every request, which can help to improve the performance of your application if you have any complex logic to determine the result of a flag. It's worth noting that even if you're using the database driver, Pennant will still make use of an in-memory cache to store the results of the feature flags. This means that if you need to check the value of a feature flag multiple times in a single request, it will only result in a single database query.

You can select the driver you'd like to use by setting it in the PENNANT_STORE environment variable. For example, if you wanted to use the array driver, you could set the following in your .env file:

PENNANT_STORE=array

Creating a feature flag

Now that we have an understanding of the drivers, let's take a look at how to create a feature flag.

You can create feature flag definitions like so:

use Laravel\Pennant\Feature;

Feature::define(
    'telecommunications',
    fn (): bool => config('features.telecommunications.enabled')
);

In this example, we're defining a feature flag called telecommunications that will return the value of the features.telecommunications.enabled config value. This means that if the config value is true, the feature flag will return true. If the config value is false, the feature flag will return false.

By default, Laravel passes in the authenticated user to the closure in your definition. You can access them by type hinting the App\Models\User class. For example, let's say you wanted to create a feature flag that only enables the telecommunications feature if the user is an administrator. You could do so like this:

use Laravel\Pennant\Feature;
use App\Models\User;

Feature::define(
    'telecommunications',
    fn (User $user): bool => $user->isAdmin(),
);

The logic defined in the closures can also become more complex if needed. For instance, imagine that you're redesigning your application's user interface (UI). You may want to display the new UI to the admins of your application, any users who have joined your beta program, or randomly selected users. You could do so like this:

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

Feature::define(
    'new-ui',
    fn (User $user): bool|Lottery => match (true) {
        $user->isAdmin(), $user->hasJoinedBetaProgram() => true,
        default => Lottery::odds(0.5),
    }
);

In the code example, we're returning true from the closure if the user is an admin or has joined the beta program. Otherwise, we're returning an instance of Laravel's Lottery class, which will randomly determine true or false with a 50% chance of either.

To use these feature flags in your code, you'll need to register them in your Laravel application. You can do this by adding them to the boot method of a service provider. You may want to create your own service provider specifically for holding feature flag definitions (such as a FeatureServiceProvider). However, for the purposes of this example, we'll add them to the boot method of the app/Providers/AppServiceProvider.php file:

namespace App\Providers;

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


final class AppServiceProvider extends ServiceProvider
{
    // ...

    public function boot(): void
    {
        Feature::define(
            'new-ui',
            fn (User $user): bool|Lottery => match (true) {
                $user->isAdmin(), $user->hasJoinedBetaProgram() => true,
                default => Lottery::odds(0.5),
            }
        );
    }
}

Now that we've registered the feature flag definition, we'll be able to check its value in our code. We'll look at how to do this later in the article.

Class-based feature flags

As your feature flags become more complex, you may want to consider lifting the code out of the closure and into a class. This can help to keep your code clean and maintainable.

Continuing with our new-ui feature flag, let's take a look at how we could lift the code out of the closure and into a class.

To begin with, we can create a new feature flag class by running the following Artisan command:

php artisan pennant:feature NewUI

Running this command should create a new app/Features/NewUI.php file that looks like this:

namespace App\Features;

use Illuminate\Support\Lottery;

class NewUI
{
    /**
     * Resolve the feature's initial value.
     */
    public function resolve(mixed $scope): mixed
    {
        return false;
    }
}

We'll update the class to look like so:

namespace App\Features;

use App\Models\User;
use Illuminate\Support\Lottery;

class NewUI
{
    public function resolve(User $user): bool|Lottery
    {
        return match (true) {
            $user->isAdmin(), $user->hasJoinedBetaProgram() => true,
            default => Lottery::odds(0.5),
        };
    }
}

In the code example, we've moved the logic from the closure into the resolve method of the class. We've also type hinted the $user parameter as the App\Models\User class. You may have also noticed that we've added bool|Lottery as the return type. This specifies that the method will return either a bool or a Lottery instance.

Defining the feature flags inside classes like this removes the need to use the Feature::define() method in our code or to register the feature flag definition in a service provider.

Using feature flags

Now that we know how to create feature flag definitions, let's take a look at how we can use them in our code.

To check the value of a feature flag, we can use the Feature::active() method:

use Laravel\Pennant\Feature;

if (Feature::active('telecommunications')) {
    // Telecommunications is enabled...
} else {
    // Telecommunications is disabled...
}

There may be times when you want to check that at least one feature is active. To do this, we can use the Feature::someAreActive() method:

use Laravel\Pennant\Feature;

if (Feature::someAreActive(['telecommunications', 'email'])) {
    // At least one of the features is enabled...
} else {
    // None of the features are enabled...
}

Similarly, you may want to check that all the features are active. To do this, we can use the Feature::allAreActive() method:

use Laravel\Pennant\Feature;

if (Feature::allAreActive(['telecommunications', 'email'])) {
    // All the features are enabled...
} else {
    // Not all the features are enabled...
}

There may also be times when you want to check whether a feature is inactive. Although you could use the !Feature::active(), Pennant provides a useful Feature::inactive() method that helps to make your code read as plain English as possible. You can use this method like so:

use Laravel\Pennant\Feature;

if (Feature::inactive('telecommunications')) {
    // Telecommunications is disabled...
} else {
    // Telecommunications is enabled...
}

By default, when checking the value of a feature flag, the value will be checked for the authenticated user. However, there may be times when you want to check the value of a feature flag for a different user. To do this, you can use the Feature::for() method:

use App\Models\User;
use Laravel\Pennant\Feature;

// Find another user that we want to check the feature flag for...
$anotherUser = User::find(2);

if (Feature::for($anotherUser)->active('telecommunications')) {
    // Telecommunications are enabled for the given user...
} else {
    // Telecommunications are disabled for the given user...
}

If you're using a class-based feature flag, you can pass the class's name to the methods we've shown above:

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

if (Feature::active(NewUI::class)) {
    // New UI is enabled...
} else {
    // New UI is disabled...
}

Toggling feature flags

As we've briefly covered, the results of the feature flags are stored either in an in-memory array or the database depending on which driver you're using. This means that the logic for a feature flag will typically only be run once.

However, you may want to update these stored values to change the result of a feature flag. For example, let's say that after a user has upgraded their subscription, you want to enable a new feature for them. To do this, you can use the Feature::activate() method:

use Laravel\Pennant\Feature;

Feature::activate('telecommunications');

Similarly, imagine a user downgrades their subscription so you want to disable a feature. You can use the Feature::deactivate() method like this:

use Laravel\Pennant\Feature;

Feature::deactivate('telecommunications');

As mentioned previously, Pennant will scope the feature flag to the authenticated user by default. However, you may want to activate or deactivate a feature flag for a different user. For instance, your application may have an admin panel that allows employees at your company to enable features at the click of a button for specific clients after they've upgraded their subscription.

To change the scope of the feature flag and activate it for a different user, you can use the Feature::for() method, followed by activate or deactivate:

use App\Models\User;
use Laravel\Pennant\Feature;

// Find another user that we want to activate the feature flag for...
$anotherUser = User::find(2);

Feature::for($anotherUser)->activate('telecommunications');

If you've been building a feature for your application and have only activated it for a subset of users, you may want to activate it for everyone after you're happy with its stability. To do this, you can use the Feature::activateForEveryone() method:

use Laravel\Pennant\Feature;

Feature::activateForEveryone('telecommunications');

Likewise, you can call the Feature::deactiveForEveryone() method if you want to deactivate the method for everyone. You may want to do this if you come across a large bug in your code and want to prevent access to that feature until it's fixed. You can do so like this:

use Laravel\Pennant\Feature;

Feature::deactivateForEveryone('telecommunications');

Using feature flag middleware

Pennant ships with an EnsureFeaturesAreActive middleware class. You can use it to only allow access to given routes in your application if the feature flag is active. If the feature flag is not active, the middleware will return a 400 Bad Request response.

Let's take a look at an example of how we can use middleware. Imagine we have several routes in our application that are related to the telecommunications feature and that we only want to allow access to those routes if the feature is active. We can do so like this:

use App\Http\Controllers\TelecommunicationsController;
use Illuminate\Support\Facades\Route;
use Laravel\Pennant\Middleware\EnsureFeaturesAreActive;

Route::middleware([EnsureFeaturesAreActive::using('telecommunications')])->group(function () {
    Route::get('/telecommunications', [TelecommunicationsController::class, 'index']);

    // ...
});

In the code example, we've wrapped the telecommunications routes inside a route group using the group method. We've also applied the EnsureFeaturesAreActive middleware to that group and passed the name of the feature flag to the using method.

The EnsureFeaturesAreActive middleware requires that all the feature flags passed to the using method are active.

Here is an example:

use App\Http\Controllers\TelecommunicationsController;
use Illuminate\Support\Facades\Route;
use Laravel\Pennant\Middleware\EnsureFeaturesAreActive;

Route::middleware([EnsureFeaturesAreActive::using('telecommunications', 'new-ui')])->group(function () {
    Route::get('/telecommunications', [TelecommunicationsController::class, 'index']);

    // ...
});

In the example above, we've specified that the authenticated user must have both the telecommunications and new-ui feature flags active to access the routes inside the route group. If either of them are inactive, the middleware will return a 400 Bad Request response and prevent access.

Using feature flags in Blade views

Pennant also ships with a useful @feature Blade directive that allows you to check the value of a feature flag in your Blade views and render different content based on the result.

Let's take a look at what it might look like inside your Blade views:

@feature('telecommunications')
    Telecommunications is active
@else
    Telecommunications is inactive
@endfeature

In the code example above, we're using the @feature to check if the telecommunications feature is enabled. If it is, we'll render the "Telecommunications is active" text. Otherwise, we'll render the "Telecommunications is inactive" text.

A great use case for this directive is for only rendering a menu item in your application's navigation if the feature is active. For example, let's say you have a telecommunications feature in your application and only want to show the link for it in your navigation menu if the feature is active. You could do so like this:

@feature('telecommunications')
    <a href="{{ route('telecommunications.index') }}">Telecommunications</a>
@endfeature

Using the HasFeatures trait

Pennant ships with a handy Laravel\Pennant\Concerns\HasFeatures trait that you can add to your User models. This trait provides a features method on the model that you can then use to check the status of the feature flags for the user.

Using the features method gives us access to all the same methods that we'd have access to if we were using the Feature class directly. This means that we can use the active, inactive, someAreActive, allAreActive methods, and so on.

Let's take a look at how we can use it in our App\Models\User model.

We'll first need to add the Laravel\Pennant\Concerns\HasFeatures trait to our User model:

namespace App\Models;

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

class User extends Authenticatable
{
    use HasFeatures;

    // ...
}

If we wanted to check if a feature is active for our authenticated user, we could do so like this:

auth()->user()->features()->active('telecommunications');

Similarly, if we wanted to check if a feature is active for another user, we could do the following:

User::find(1)->features()->active('telecommunications');

Rich-value feature flags

Although you'll typically return Boolean values (true or false) from your feature flag definitions, Pennant also supports rich-value feature flags. This means that you can return any value from your feature flag definitions, such as a string or an array.

This can be particularly handy if you're using Pennant to implement some form of A/B testing. For instance, imagine that you have a button in your application that your users can click to upgrade their account to a higher tier. You might want to test different text for the button to see which one performs better.

So you may want to create a feature flag definition like this:

Feature::define(
    'upgrade-button-text',
    fn (User $user): bool => Arr::random([
        'Upgrade now',
        'Upgrade to PRO!',
    ])
);

In this feature flag definition, we're declaring an upgrade-button-text feature flag that will return a random string from the array in the closure.

We can then get the value of the feature flag by using the Feature::value() method. If we wanted to output the value of the feature flag in our Blade views, we could do so like this:

<a href="/upgrade">
    {{ Feature::value('upgrade-button-text') }}
</a>

Testing feature flags

Writing tests for your applications is an important part of the development process. It helps to give you confidence that your code works, reduces the likelihood of bugs, and makes it easier to make changes to your code in the future without fear of breaking the existing functionality.

When it comes to testing feature flags, there are two main approaches that you can use:

  1. Write a single test that tests the feature flag definition and the code that uses the feature flag.
  2. Write separate tests for the feature flag definition and write separate tests for the code that uses the feature flag.

Let's say that you choose option one and write a single test that tests the feature flag definition and the code that uses the feature flag, such as a controller. In the setup of your test, you need to make sure that the feature flag's conditions can be passed. For example, let's take the following Telecommunications feature flag definition:

namespace App\Features;

use App\Models\User;

class Telecommunications
{
    public function resolve(User $user): bool
    {
        return $user->isAdmin() || $user->has_joined_beta_program;
    }
}

If you wanted to write a test for a controller that was using this feature flag, you'd need to make sure that the user you created for your test is either an admin or has joined the beta program. In this case, it's a relatively simple feature, so it's easy to do. However, if the feature flag were more complex, it could be more difficult to set up the conditions for the feature flag to pass.

If the feature flag has a complex definition, and you're using it in multiple places in your code, it may lead to you writing a lot of duplicate setup code in your tests. This can be time-consuming and error-prone. It also means that if you need to update the feature flag definition, you'll need to update all the tests that use it.

You may want to use this approach for some mission-critical parts of your application, where you want to be as confident as possible that the feature flag is working as expected.

Another benefit of this approach is that you're never directly interacting with Pennant in your test code. This means that if you choose to use a different approach in the future for your feature flags (such as moving away from using Pennant and creating your own feature flag manager), you won't need to update your tests because you're testing that the behavior hasn't changed.

The other approach that you can take to test your application is to write separate tests for the feature flag definition and the code that uses the feature flag. This would involve writing tests for the feature flag definition itself and separate tests for the code that uses the feature flag. This allows you to test the feature flag definition in isolation and then mock the result of the feature flag in your tests for the code that uses it. As a result, this approach can be quicker to set up and can help to reduce the amount of duplicate code in your tests.

Let's take a look at how to use this approach.

Imagine that we're using our Telecommunications feature flag class defined above. We'll be using it with the EnsureFeaturesAreActive middleware to only allow access to the telecommunications routes if the feature flag is active. The routes may look something like so:

use App\Features\Telecommunications;
use App\Http\Controllers\TelecommunicationsController;
use Illuminate\Support\Facades\Route;
use Laravel\Pennant\Middleware\EnsureFeaturesAreActive;

Route::middleware([EnsureFeaturesAreActive::using(Telecommunications::class)])->group(function () {
    Route::get('/telecommunications', [TelecommunicationsController::class, 'index'])->name('telecommunications.index');

    // ...
});

For the purposes of this example, the TelecommunicationsController will be very basic and just return a view:

namespace App\Http\Controllers;

use Illuminate\Contracts\View\View;

class TelecommunicationsController extends Controller
{
    public function index(): View
    {
        return view('telecommunications.index');
    }

    // ...
}

We'll first write a test for our controller. We'll want to test that a 200 OK response is returned if the feature flag is active, and a 400 Bad Request response is returned if the feature flag is inactive.

Let's take a look at what the tests may look like and then we'll discuss what's being done:

namespace Tests\Feature\Http\Controllers\TelecommunicationsController;

use App\Features\Telecommunications;
use App\Models\User;
use Illuminate\Foundation\Testing\LazilyRefreshDatabase;
use Laravel\Pennant\Feature;
use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase;

class IndexTest extends TestCase
{
    use LazilyRefreshDatabase;

    #[Test]
    public function view_can_be_returned(): void
    {
        // Override the feature so we're forcing it to be enabled.
        Feature::define(
            feature: Telecommunications::class,
            resolver: true
        );

        $this->actingAs(User::factory()->create())
            ->get(route('telecommunications.index'))
            ->assertOk()
            ->assertViewIs('telecommunications.index');
    }

    #[Test]
    public function error_is_returned_if_the_feature_is_disabled_for_the_user(): void
    {
        // Override the feature so we're forcing it to be enabled.
        Feature::define(
            feature: Telecommunications::class,
            resolver: false
        );

        $this->actingAs(User::factory()->create())
            ->get(route('telecommunications.index'))
            ->assertBadRequest();
    }
}

In the first test (view_can_be_returned), we're overriding the Telecommunications feature flag using the Feature::define() method to force the value to always be true. This means that when the feature flag is checked when we make our request, we'll already have a true value stored for the user. This is simulating a scenario where the user satisfies a condition for the feature to be enabled. As a result, the 200 OK response will be returned with the correct view.

In the second test (error_is_returned_if_the_feature_is_disabled_for_the_user), we're doing the opposite and overriding the Telecommunications feature flag again to force the value to be false. This simulates a scenario where the user doesn't satisfy a condition for the feature to be enabled. As a result, the 400 Bad Request response will be returned.

Now that we've tested our controller that's using the feature flag, let's test the feature flag itself. We'll want to test the following scenarios:

  • true is returned if the user is an admin.
  • true is returned if the user has joined the beta program.
  • false is returned if the user is not an admin and has not joined the beta program.

Let's look at what our tests might look like and then discuss what's being done:

namespace Tests\Feature\Features\Telecommunications;

use App\Features\Telecommunications;
use App\Models\User;
use Illuminate\Foundation\Testing\LazilyRefreshDatabase;
use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase;

class HandleTest extends TestCase
{
    use LazilyRefreshDatabase;

    #[Test]
    public function true_is_returned_if_the_user_is_an_admin(): void
    {
        $user = User::factory()
            ->create([
                'role' => 'admin',
                'has_joined_beta_program' => false,
            ]);

        $this->assertTrue(
            (new Telecommunications())->resolve($user)
        );
    }

    #[Test]
    public function true_is_returned_if_the_user_has_joined_the_beta_program(): void
    {
        $user = User::factory()
            ->create([
                'role' => 'user',
                'has_joined_beta_program' => true,
            ]);

        $this->assertTrue(
            (new Telecommunications())->resolve($user)
        );
    }

    #[Test]
    public function false_is_returned_if_the_user_is_not_an_admin_and_has_not_joined_the_beta_program(): void
    {
        $user = User::factory()
            ->create([
                'role' => 'user',
                'has_joined_beta_program' => false,
            ]);

        $this->assertFalse(
            (new Telecommunications())->resolve($user)
        );
    }
}

For the purposes of this example, I've used a basic role field on the User model to determine if the user is an admin or not. However, in a real-world application, you'd likely have a more complex way of determining the user's role. For more details on how to do this, you can check out A Complete Guide to Managing User Permissions in Laravel Apps.

In the first test, we're creating a user that is an admin and has not joined the beta program. We're then creating a new instance of the Telecommunications feature flag class and calling the resolve method with the user. We're then asserting that the result returned is true.

In the second test, we're doing something similar and checking that the result is true if the user is not an admin but has joined the beta program.

In the final test, we're creating a user that has the role of user and has not joined the beta program. We're then asserting that the feature flag returns false for this user.

Using this approach is a great way to test your feature flags in isolation. If the feature flag definition changes in the future, you'll only need to update the test for the feature flag itself, rather than all the tests that use the feature flag. This helps to keep your test code clean and maintainable.

Conclusion

In this article, we've taken a look at what feature flags are, as well as the advantages and disadvantages of using them. We've also taken a look at how to use feature flags in Laravel using the first-party Laravel Pennant package. We then delved into how you can test feature flags in Laravel applications.

Hopefully, you should now have a good understanding of what feature flags are and feel confident enough to start using them in your own applications.

What to do next:
  1. Try Honeybadger for FREE
    Honeybadger helps you find and fix errors before your users can even report them. Get set up in minutes and check monitoring off your to-do list.
    Start free trial
    Easy 5-minute setup — No credit card required
  2. Get the Honeybadger newsletter
    Each month we share news, best practices, and stories from the DevOps & monitoring community—exclusively for developers like you.
    author photo

    Ashley Allen

    Ashley is a freelance Laravel web developer who loves contributing to open-source projects and building exciting systems to help businesses succeed.

    More articles by Ashley Allen
    Stop wasting time manually checking logs for errors!

    Try the only application health monitoring tool that allows you to track application errors, uptime, and cron jobs in one simple platform.

    • Know when critical errors occur, and which customers are affected.
    • Respond instantly when your systems go down.
    • Improve the health of your systems over time.
    • Fix problems before your customers can report them!

    As developers ourselves, we hated wasting time tracking down errors—so we built the system we always wanted.

    Honeybadger tracks everything you need and nothing you don't, creating one simple solution to keep your application running and error free so you can do what you do best—release new code. Try it free and see for yourself.

    Start free trial
    Simple 5-minute setup — No credit card required

    Learn more

    "We've looked at a lot of error management systems. Honeybadger is head and shoulders above the rest and somehow gets better with every new release."
    — Michael Smith, Cofounder & CTO of YvesBlue

    Honeybadger is trusted by top companies like:

    “Everyone is in love with Honeybadger ... the UI is spot on.”
    Molly Struve, Sr. Site Reliability Engineer, Netflix
    Start free trial
    Are you using Sentry, Rollbar, Bugsnag, or Airbrake for your monitoring? Honeybadger includes error tracking with a whole suite of amazing monitoring tools — all for probably less than you're paying now. Discover why so many companies are switching to Honeybadger here.
    Start free trial
    Stop digging through chat logs to find the bug-fix someone mentioned last month. Honeybadger's built-in issue tracker keeps discussion central to each error, so that if it pops up again you'll be able to pick up right where you left off.
    Start free trial
    “Wow — Customers are blown away that I email them so quickly after an error.”
    Chris Patton, Founder of Punchpass.com
    Start free trial