Laravel Magazine

Feature Flags Made Simple with Laravel Pennant

Eric Van Johnson ·
Feature Flags Made Simple with Laravel Pennant

Feature flags are one of those things that seem like overkill until the day you desperately need one. You're in the middle of a deployment, something's wrong with the new checkout flow for 3% of users, and instead of a rollback, you flip a flag and the problem disappears in seconds.

Laravel Pennant is the first-party answer to this need. It's lightweight, integrates naturally with Laravel, and has a clean API that gets out of your way.

This tutorial covers everything from installation to running a proper percentage-based rollout.


Installation

composer require laravel/pennant
php artisan vendor:publish --provider="Laravel\Pennant\PennantServiceProvider"
php artisan migrate

Publishing the service provider creates a config/pennant.php configuration file and a features migration. The migration adds a features table that Pennant's database driver uses to store resolved flag values.


Storage Drivers

Pennant supports two storage drivers.

The array driver stores resolved values in memory for the duration of the request only. Nothing is persisted. Useful for testing.

The database driver (default) stores resolved values in your features table. Once a flag is resolved for a given user, that value is persisted and returned on subsequent requests without re-running the closure. This is what you want in production.

Configure it in config/pennant.php:

'default' => env('PENNANT_STORE', 'database'),

Defining Your First Feature

Features are typically defined in a service provider. The AppServiceProvider is a natural home for them, or you can create a dedicated FeatureServiceProvider.

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

class AppServiceProvider extends ServiceProvider
{
    public function boot(): void
    {
        Feature::define('new-checkout-flow', fn (User $user) => $user->created_at->isAfter(now()->subDays(30)));
    }
}

This defines a feature called new-checkout-flow that is active for users who registered in the last 30 days. Existing users get the old flow. New users get the new one.

The closure receives the "scope" -- by default, the authenticated user. It should return a boolean.


Checking Features in Code

Once defined, checking a feature is a single method call:

use Laravel\Pennant\Feature;

if (Feature::active('new-checkout-flow')) {
    return view('checkout.new');
}

return view('checkout.legacy');

By default, Pennant checks for the currently authenticated user. You can check for a specific user explicitly:

if (Feature::for($user)->active('new-checkout-flow')) {
    // ...
}

Checking Features in Blade

Pennant ships with Blade directives for clean template conditionals:

@feature('new-checkout-flow')
    <x-checkout-new />
@else
    <x-checkout-legacy />
@endfeature

No Feature::active() in the template. Clean and readable.


Percentage-Based Rollouts

This is where Pennant really earns its place. Rolling out to a percentage of users is a common strategy for de-risking releases. Pennant makes it trivial:

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

Feature::define('new-dashboard', Lottery::odds(1, 10));

Lottery::odds(1, 10) gives each user a 1-in-10 (10%) chance of receiving the feature. Once resolved for a user, the result is stored in the database -- that user stays in the same group consistently. No flickering between enabled and disabled on subsequent requests.

To ramp up the rollout over time, update the lottery odds:

// Week 1: 10% rollout
Feature::define('new-dashboard', Lottery::odds(1, 10));

// Week 2: 50% rollout
Feature::define('new-dashboard', Lottery::odds(1, 2));

// Full rollout
Feature::define('new-dashboard', true);

When you pass true as the value, the feature is active for everyone. Passing false disables it for everyone.


A/B Testing

Feature flags are the foundation of A/B testing. Here's a simple pattern:

Feature::define('button-color', function (User $user) {
    return $user->id % 2 === 0 ? 'blue' : 'green';
});

In your controller or view:

$buttonColor = Feature::value('button-color'); // returns 'blue' or 'green'

Note the difference: Feature::active() checks whether a feature is truthy. Feature::value() returns the actual value the closure resolved to. Use value() when your feature flag carries data beyond just on/off.


Class-Based Features

For complex features that need their own logic, you can define features as dedicated classes instead of closures:

php artisan pennant:feature NewCheckoutFlow

This generates app/Features/NewCheckoutFlow.php:

<?php

namespace App\Features;

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

class NewCheckoutFlow
{
    public function resolve(User $user): mixed
    {
        // Complex eligibility logic goes here
        if ($user->isSubscriber()) {
            return true; // Always on for subscribers
        }

        return Lottery::odds(1, 5); // 20% for everyone else
    }
}

Register it in your service provider:

Feature::define(NewCheckoutFlow::class);

And check it:

if (Feature::active(NewCheckoutFlow::class)) {
    // ...
}

Using class-based features keeps your service provider clean when you have many flags.


Deactivating and Purging Features

To disable a feature for a specific user:

Feature::for($user)->deactivate('new-checkout-flow');

To enable it for a specific user regardless of the closure result:

Feature::for($user)->activate('new-checkout-flow');

To remove all stored values for a feature (forcing re-evaluation on next request):

Feature::purge('new-checkout-flow');

purge() is useful when you change a feature's logic and need everyone to be re-evaluated against the new closure.


Testing With Pennant

In tests, you can force feature states without touching the database:

use Laravel\Pennant\Feature;

public function test_new_checkout_shows_for_new_users(): void
{
    Feature::activate('new-checkout-flow');

    $response = $this->actingAs($this->user)->get('/checkout');

    $response->assertViewIs('checkout.new');
}

public function test_legacy_checkout_shows_for_old_users(): void
{
    Feature::deactivate('new-checkout-flow');

    $response = $this->actingAs($this->user)->get('/checkout');

    $response->assertViewIs('checkout.legacy');
}

Checking Multiple Features at Once

When you need to check whether a user has access to several features in a single query, Pennant's Feature::some() and Feature::all() methods save you multiple calls:

// Returns true if the user has ANY of these features active
if (Feature::some(['new-checkout-flow', 'express-checkout'])) {
    // show enhanced checkout options
}

// Returns true only if the user has ALL of these features active
if (Feature::all(['beta-ui', 'new-dashboard'])) {
    // show full beta experience
}

This is particularly useful for gating entire feature sets or checking eligibility for a combined experience without writing a chain of Feature::active() calls.


Wrapping Up

Pennant is one of those packages that quietly solves a whole category of problem. Feature flags are how mature engineering teams ship without fear -- you merge to main, deploy to production, and control what users see with a flag rather than a deployment. When something's wrong, you turn it off.

Full documentation is available at laravel.com/docs/13.x/pennant.


Sources:

Stay Updated

Subscribe to our newsletter

Get latest news, tutorials, community articles and podcast episodes delivered to your inbox.

Weekly articles
We send a new issue of the newsletter every week on Friday.
No spam
We'll never share your email address and you can opt out at any time.