Laravel Pennant is Laravel’s official, first-party feature flag package. It lets you deploy code to production with a feature switched off, then enable it for internal staff, a percentage of users, or specific teams. If something breaks, you can switch it off again. Pennant stores flag values in your database, works with PHP, Blade, and route middleware, and requires no external service. The source code is available on GitHub.
It’s Friday afternoon. You merge a new checkout flow, deploy it, and within ten minutes, support tickets start arriving. Your options are a rushed hotfix or a rollback that also undoes three other changes. With a feature flag, you can switch back to the old checkout with one command while you fix the bug calmly.
That’s the real value of Laravel feature flags: they separate deploying code from releasing it.
In this Pennant tutorial, you’ll install Pennant, define your first flag, roll it out gradually, build a kill switch, flag features per team, run a simple A/B test, test flagged code, and clean up old flags before they pile up.
Key Takeaways
Pennant is free and first-party. Install it with
composer require laravel/pennant. By default, it stores resolved values in afeaturestable, or it can use the in-memoryarraydriver.Flags resolve once per scope and then stick. The first check runs your closure, and subsequent checks read the stored value, keeping users consistently in or out of a rollout.
Lottery::odds(1 / 10)enables percentage rollouts. A class-based feature’sbefore()method can act as a kill switch that overrides stored values.Scopes aren’t limited to users.
Feature::for($team)lets you flag features for entire accounts, making it useful for B2B releases.Plan your cleanup. Remove obsolete flags from your code, then run
php artisan pennant:purgein your deployment pipeline.
The developers in this article’s examples are illustrative composites based on common Laravel scenarios.
Why Use Laravel Pennant for Feature Flags?
A feature flag is a runtime switch around a piece of code. You deploy the code, but whether it runs depends on a value you control.
This enables several release patterns:
Dark launches. Merge unfinished features into
mainbehind a disabled flag, without maintaining long-lived branches.Gradual rollouts. Release to 1% of users, then 10%, and eventually everyone, monitoring errors at each stage.
Kill switches. Disable a misbehaving feature without deploying new code.
Beta programs. Give specific customers or teams early access.
A/B tests. Show different variants and measure which performs better.
Pennant supports all these patterns without a third-party service. If you already have a Laravel CI/CD pipeline, feature flags are the missing piece that can make frequent deployments safer.
Installing Laravel Pennant
Install the package, publish its configuration and migration, and run the migration:
composer require laravel/pennant
php artisan vendor:publish --provider="Laravel\Pennant\PennantServiceProvider"
php artisan migrateThis creates config/pennant.php and a features table. Pennant supports two drivers (Pennant documentation):
Driver | Where values are stored | Best use |
|---|---|---|
| The | Production, where values need to persist across requests and servers |
| In memory for one request | Tests and local experiments |
Defining Your First Feature Flag
The quickest approach is to define a closure in AppServiceProvider::boot(). By default, the closure receives the scope, which is the logged-in user:
use App\Models\User;
use Illuminate\Support\Lottery;
use Laravel\Pennant\Feature;
public function boot(): void
{
Feature::define('new-checkout', fn (User $user) => match (true) {
$user->isStaff() => true, // staff always see it
$user->isEnterprise() => false, // enterprise waits for GA
default => Lottery::odds(1 / 10), // 10% of everyone else
});
}How resolution works: The first time new-checkout is checked for a user, Pennant runs the closure and stores the result. Subsequent checks read the stored value. This means a user included in the 10% rollout stays in it instead of switching between the old and new checkout on every page load.
Class-based features
For more complex flags, generate a feature class:
php artisan pennant:feature NewCheckoutnamespace App\Features;
use App\Models\User;
use Illuminate\Support\Lottery;
use Laravel\Pennant\Attributes\Name;
#[Name('new-checkout')]
class NewCheckout
{
public function resolve(User $user): mixed
{
return match (true) {
$user->isStaff() => true,
default => Lottery::odds(1 / 10),
};
}
}Use the class instead of the closure, not alongside it. Remove the Feature::define('new-checkout', ...) call and add Feature::discover() to AppServiceProvider::boot() so Pennant registers every class in app/Features up front.
This allows checks by name, such as Feature::active('new-checkout'), to resolve to the class and its before() method. Without discover(), check the feature by class instead: Feature::active(NewCheckout::class).
Class-based features can also use constructor injection. The #[Name] attribute assigns a stable name instead of using the full class name, preventing stored values from becoming orphaned if you rename the class later.
Checking Flags in Code, Blade, and Routes
In PHP:
use Laravel\Pennant\Feature;
if (Feature::active('new-checkout')) {
return $this->newCheckout($request);
}
return $this->legacyCheckout($request);Or use a more concise approach:
return Feature::when('new-checkout',
fn () => $this->newCheckout($request),
fn () => $this->legacyCheckout($request),
);Add the HasFeatures trait to your User model to use $user->features()->active('new-checkout').
In Blade:
@feature('new-checkout')
@include('checkout.new')
@else
@include('checkout.legacy')
@endfeatureOn routes, you can prevent users without the flag from accessing a new endpoint:
use Laravel\Pennant\Middleware\EnsureFeaturesAreActive;
Route::get('/checkout/v2', NewCheckoutController::class)
->middleware(EnsureFeaturesAreActive::using('new-checkout'));By default, the middleware returns a 400 Bad Request response when a flag is inactive. You can customize this behavior with EnsureFeaturesAreActive::whenInactive() in a service provider, for example, to redirect users to the old page.
Starting a new Laravel app? StackMivo generates a complete Laravel project from a plain-English description, letting you focus on release strategy instead of boilerplate.
Gradual Rollouts and Kill Switches
Rolling out step by step
Start with a small percentage of users and gradually expand the rollout. Because Pennant stores resolved values, changing Lottery::odds(1 / 10) to 1 / 2 only affects users whose flags haven’t been resolved yet.
To re-evaluate everyone against the new odds, purge the stored values. Keep in mind that this also re-evaluates users already included in the rollout, so some may lose access to the feature:
Feature::purge('new-checkout'); // everyone re-resolves on their next checkWhen you’re confident the feature is stable, release it to everyone:
Feature::activateForEveryone('new-checkout');Then update the feature definition to return true as well, because activateForEveryone() changes only the stored values.
A kill switch with before()
This is one of the most useful patterns in this guide. A class-based feature’s before() method runs in memory before Pennant reads stored values. If it returns a non-null value, that value takes precedence for the request:
use Illuminate\Support\Facades\Config;
class NewCheckout
{
public function before(User $user): mixed
{
// Kill switch: staff keep access while you investigate
if (Config::get('features.new-checkout.disabled')) {
return $user->isStaff();
}
return null; // fall through to the stored value
}
// resolve() as above...
}Connect that configuration value to an environment variable. For example, in config/features.php, you could use 'disabled' => env('NEW_CHECKOUT_DISABLED', false). This lets you disable the feature for customers without losing their stored rollout state.
If you cache configuration in production, run php artisan config:cache again after changing the environment variable. Otherwise, the old value remains in effect. Once the bug is fixed, switch the setting back, and the same users can access the feature again.
Ana, a backend developer at a Lisbon e-commerce startup, shipped a new address autocomplete feature behind Pennant in June. Fifteen minutes into a 20% rollout, error rates spiked because postcodes in one country broke the third-party API. She activated the kill switch, kept the feature enabled for staff, and shipped a fix that afternoon. Customers experienced only a few failed lookups, and the rollout resumed for the same 20% of users the next morning.
Team Flags and Rich Values
Flag whole accounts, not individual users
In B2B applications, it can be confusing when one user sees a new feature but their colleague doesn’t. Instead, scope flags to the team:
Feature::define('billing-v2', fn (Team $team) => $team->plan === 'beta');
if (Feature::for($user->team)->active('billing-v2')) {
return redirect('/billing/v2');
}If most of your flags are team-based, you can set the default scope once:
Feature::resolveScopeUsing(fn ($driver) => Auth::user()?->team);This changes the default scope for every flag. Any flag whose closure expects a User will then receive a Team and fail. Use this approach only when all your flags are team-based, or explicitly check user-scoped flags with Feature::for($user).
Watch out for null scopes. Queued jobs, Artisan commands, and guest routes don’t have a logged-in user, so the default scope is null. Pennant returns false for definitions that don’t accept null. In jobs, either pass a scope explicitly with Feature::for(...) or type the closure as User|null and handle the null case.
Rich values for A/B tests
Feature flags don’t have to return only true or false. They can return different values:
use Illuminate\Support\Arr;
Feature::define('pricing-layout', fn (User $user) => Arr::random(['cards', 'table']));@feature('pricing-layout', 'cards')
@include('pricing.cards')
@elsefeature('pricing-layout', 'table')
@include('pricing.table')
@else
@include('pricing.cards') {{-- guests and anyone without a variant --}}
@endfeatureEach user is assigned a variant once and keeps it. Log Feature::value('pricing-layout') alongside your conversion events to create a basic A/B test.
Avoid N+1 flag checks
Checking a flag inside a loop can trigger one query per scope. Eager-load the flags first, using the same scope type the feature expects:
Feature::for($users)->load(['new-checkout']); // user-scoped flag
Feature::for($teams)->load(['billing-v2']); // team-scoped flag<!-- TODO BEFORE PUBLISHING: insert a complete YouTube embed block (opening and closing wp:embed tags) showing Laravel Pennant setup or feature flag rollouts, ideally from the official Laravel YouTube channel. If none is available, delete this comment. -->
Testing Laravel Pennant Feature Flags
The simplest approach is to redefine the flag at the start of each test:
use Laravel\Pennant\Feature;
it('shows the new checkout when the flag is on', function () {
Feature::define('new-checkout', true);
$this->actingAs(User::factory()->create())
->get('/checkout')
->assertSee('Express checkout');
});
it('shows the legacy checkout when the flag is off', function () {
Feature::define('new-checkout', false);
$this->actingAs(User::factory()->create())
->get('/checkout')
->assertDontSee('Express checkout');
});Redefine the flag in the same way your application checks it. If your code calls Feature::active(NewCheckout::class), use Feature::define(NewCheckout::class, true) in the test instead.
Set PENNANT_STORE=array in phpunit.xml so tests never write to the features table.
Test both paths for every flag. The inactive path is the one you’ll fall back to during an incident, yet it’s often the one developers forget to test. Our Laravel testing with AI guide explains how to generate these test pairs quickly.
Cleaning Up Old Flags
Feature flags are temporary. If you leave them in place, your codebase fills with dead branches that nobody dares to remove. Once a feature is fully released:
Remove the flag checks from your code, Blade templates, and routes, keeping only the winning path.
Delete the definition or feature class.
Purge stored values as part of your deployment:
php artisan pennant:purge new-checkoutExplicitly naming flags is the safest approach. The pennant:purge --except-registered command purges everything not registered in a service provider. This can include class-based flags that are still rolling out and can wipe their stored state. Use it only if every live flag is defined with Feature::define() or registered through Feature::discover().
Add the purge step to your deployment script, whether you use Forge, Envoyer, or a manual VPS deployment over SSH.
To catch unexpected references, log Pennant’s UnknownFeatureResolved event:
Event::listen(function (UnknownFeatureResolved $event) {
Log::warning("Unknown feature checked: {$event->feature}");
});Marco leads a five-developer team at a SaaS company in Milan. After a year of using feature flags, the team had 40 defined flags, and nobody knew which ones were safe to remove. They now create a removal ticket when a flag is introduced, review flags monthly, and purge each retired flag by name in the same deployment that removes its code. Their flag count stays around eight.
Want a Laravel app that’s ready for feature flags from day one? Describe your app, and StackMivo generates the models, authentication, admin panel, and tests. Add Pennant in one command. Start free with StackMivo →
Frequently Asked Questions
What is Laravel Pennant?
Laravel Pennant is Laravel’s first-party feature flag package. It lets you enable or disable features for individual users, teams, or everyone, roll them out gradually, and store flag values in your database.
Is Laravel Pennant free?
Yes. Pennant is an open-source package maintained by the Laravel team. You install it with Composer, and it uses your existing database, so there’s no additional service to pay for.
How do I do a percentage rollout with Pennant?
Return Lottery::odds(1 / 10) (or another ratio) from your feature definition. Each user is assigned a value once, and the result is stored so they remain consistently included in or excluded from the rollout.
Can Pennant flag features for teams instead of users?
Yes. Define the feature with a Team parameter and check it with Feature::for($user->team)->active('feature-name'). You can also set the team as the default scope using Feature::resolveScopeUsing().
How do I test code that uses Pennant?
Redefine the flag at the start of each test, for example, with Feature::define('new-checkout', true), and set PENNANT_STORE=array in phpunit.xml. Test both the enabled and disabled paths.
Should I use Pennant or a hosted feature flag service?
Pennant handles rollouts, kill switches, team flags, and simple A/B tests within your Laravel application. Hosted services offer additional features, such as dashboards, cross-platform SDKs, and experiment analytics. Many teams start with Pennant and consider a hosted service only when they outgrow its capabilities.
The Bottom Line
Laravel Pennant enables safe, gradual releases with very little setup: one package, one table, and a facade you already know how to use. Define flags in code, roll them out with lotteries, protect your application with a before() kill switch, and test both paths.
The most important habit is cleanup. Every flag you add is a flag you’ll eventually need to remove, so plan its removal from the day you create it.
Build your next Laravel app with StackMivo and ship features safely from day one →