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: