Laravel Pennant: The Complete Feature Flags Guide
A practical guide to Laravel Pennant: scopes, rich values, percentage rollouts, and the sticky-storage gotcha that silently breaks rollouts.
Most Laravel apps already have feature flags. They just live in the worst possible place: a config/features.php file full of booleans, an .env variable read in three different controllers, or an if ($user->id === 1) that someone swore they'd remove before merging. It works until it doesn't, and then you're deploying code to change who sees a feature, which is the exact thing feature flags are supposed to prevent.
Laravel Pennant is the first-party fix. It's small, it has no external service to pay for, and once the mental model clicks it replaces every one of those hacks with one consistent API. But that mental model has one sharp edge that trips up almost everyone the first time, and most tutorials skip right past it. So this guide covers the whole thing: defining flags, scoping them to users or teams, percentage rollouts, rich values, testing, and the sticky-storage behavior that will silently break your first rollout if you don't know it's there.
Installing Pennant
Nothing surprising here:
composer require laravel/pennant
php artisan vendor:publish --provider="Laravel\Pennant\PennantServiceProvider"
php artisan migrate
The publish step drops a config/pennant.php file and a migration that creates a features table. That table is where Pennant's default driver stores resolved flag values, and understanding what "resolved" means there is the key to the whole package. More on that shortly.
Pennant ships two storage drivers. The database driver (the default) persists resolved values in that features table, so a value survives across requests, deployments, and server restarts. The array driver keeps values in memory for a single request and forgets them after, which makes it the right choice for tests. You pick the default in config/pennant.php.
Defining Your First Flag
Flags are defined with the Feature::define method, usually in a service provider's boot method. You give the flag a name and a closure that resolves its value. The closure receives the "scope", which by default is the currently authenticated user.
// app/Providers/AppServiceProvider.php
use App\Models\User;
use Laravel\Pennant\Feature;
public function boot(): void
{
Feature::define('new-checkout', fn (User $user) =>
$user->created_at->isAfter(now()->subDays(30))
);
}
This flag is active for users who registered in the last 30 days. Everyone else gets the old checkout. Checking it anywhere in your app is one call:
use Laravel\Pennant\Feature;
if (Feature::active('new-checkout')) {
return view('checkout.new');
}
return view('checkout.legacy');
Feature::active() checks against the current user. To check a specific scope, use for():
if (Feature::for($user)->active('new-checkout')) {
// ...
}
In Blade, there's a directive that reads cleaner than an inline check:
@feature('new-checkout')
<x-checkout.new />
@else
<x-checkout.legacy />
@endfeature
That's the entire happy path. If Pennant were only this, it'd be a nicer syntax for booleans. What makes it worth adopting is scope and persistence, which is also where the trap lives.
The One Thing That Trips Everyone Up
Here's the behavior that surprises people, and it's worth stopping on because it will bite you otherwise.
With the database driver, a flag's value is resolved once per scope and then stored. The closure runs the first time you check the flag for a given user. After that, Pennant reads the saved value from the features table and never runs your closure again for that user.
Read that again, because the consequence is not obvious. If you define a flag as a 10% rollout, check it for your users, and then later change the closure to 25%, nothing happens. The users who were already resolved keep whatever value they got the first time. Your edit to the closure is ignored for everyone who's already been evaluated.
This is a feature, not a bug. It's what gives you a consistent user experience: a user who got the new checkout doesn't randomly lose it on their next request because a lottery re-rolled. But if you don't know it's happening, your first percentage rollout will look completely broken. You'll bump the number, refresh, and see no change, and you'll think Pennant is ignoring you.
The fix is to purge the stored values so they re-resolve against the new closure:
php artisan pennant:purge new-checkout
Now the next check for each user re-runs the closure at the new percentage. Treat this like a deploy step: whenever you change a flag's resolution logic and want existing users re-evaluated, purge it. I script it into the deploy alongside the migration, the same way you'd run route:cache (the full command reference is in the Laravel Artisan Commands guide).
Percentage Rollouts With Lottery
The most common reason to reach for flags is the gradual rollout: ship to 10% of users, watch your error rates, then widen. Pennant leans on Laravel's Illuminate\Support\Lottery helper for this, and the official Pennant documentation uses the same pattern.
use App\Models\User;
use Illuminate\Support\Lottery;
use Laravel\Pennant\Feature;
Feature::define('new-checkout', fn (User $user) => match (true) {
$user->isInternalTester() => true,
default => Lottery::odds(1, 10),
});
Internal testers always get the feature. Everyone else has a 1-in-10 chance, resolved once and then stored. Because the result is sticky, a user who wins the lottery keeps the feature on every subsequent request instead of flickering on and off as the lottery re-rolls. That consistency is the whole point. And it's exactly why widening the rollout with Lottery has a trap of its own, which the next section covers.
This is the exact interaction that surprises people, so it's worth saying once more in context: the closure and the storage work together. The closure decides the value the first time; storage remembers it forever after. Rollout percentage changes are storage operations, not code edits alone.
Widening the Rollout Without Kicking Users Out
Here's the trap, and credit to a reader named Gábor for asking the question that exposed it. Say you're at 10% with Lottery, and you widen to 25% by editing the odds and running pennant:purge. What happens to the users who already had the feature?
They re-roll. Lottery is a fresh random draw every time the closure runs, and the purge deleted the stored values that made their win sticky. So your original 10% now face a brand-new 25% lottery like everyone else, and each of them has only a 25% chance of keeping the feature. On average, 7.5% of your entire user base had the feature yesterday and loses it today. That's the exact experience sticky storage exists to prevent, reintroduced by the purge.
The fix is to stop rolling dice and hash instead. Assign each user a fixed bucket from 0 to 99:
Feature::define('new-checkout', fn (User $user) =>
(crc32('new-checkout:'.$user->id) % 100) < 25
);
A user's bucket never changes. At 10%, buckets 0 through 9 are in. Raise the threshold to 25% and buckets 0 through 24 are in, which includes the original 10% by construction. Nobody who had the feature loses it, and pennant:purge becomes completely safe because re-resolution always gives the same user the same answer. Prefixing the feature name into the hash keeps buckets independent per flag, so the same users don't win every rollout first. This is the same bucketing technique hosted tools like LaunchDarkly and Unleash use internally.
So when do you use which? Deterministic buckets for feature rollouts, where a user noticing the feature disappear is a support ticket. Lottery is still fine when membership churn doesn't matter, like assigning cosmetic A/B variants where being reshuffled once is harmless.
Scoping Flags to Teams and Accounts
The default scope is the authenticated user, but plenty of rollouts happen per team or per account, not per person. If one member of a company sees the new billing screen and their colleague doesn't, that's a support ticket waiting to happen.
Scope to the team by type-hinting it in the closure and passing it to for():
Feature::define('billing-v2', fn (Team $team) =>
in_array($team->plan, ['pro', 'enterprise'])
);
if (Feature::for($request->user()->currentTeam)->active('billing-v2')) {
// ...
}
If you get tired of writing for() everywhere, implement the HasFeatures trait's default-scope method or set a global default scope in your service provider so every check resolves against the team automatically. For a SaaS app, setting the default scope to the current team once is usually cleaner than threading for() through every call site.
The Null Scope Trap in Queues and Commands
This one pairs with the sticky-storage gotcha as the second thing that bites people, and it shows up specifically outside the HTTP request.
Your flag closure type-hints User. That's fine in a controller where someone's logged in. But check the same flag inside a queued job, an Artisan command, or an unauthenticated route, and there's no authenticated user, so the scope is null. When the scope is null and your closure doesn't accept null, Pennant skips the closure entirely and returns false.
So a flag that's active for a user in the browser silently reads as inactive when the same logic runs in a queued job processing that user's data. If your job's behavior depends on the flag, it quietly takes the wrong branch.
Two ways to handle it. Either pass the scope explicitly in contexts that have no authenticated user:
// Inside a job that knows which user it's working on
if (Feature::for($this->user)->active('new-checkout')) {
// ...
}
Or make the closure null-safe so it fails predictably instead of surprisingly:
Feature::define('new-checkout', fn (User $user = null) =>
$user?->created_at->isAfter(now()->subDays(30)) ?? false
);
The explicit-scope version is what I reach for, because a flag that depends on a user should always be told which user. Relying on the ambient authenticated user is exactly how the browser-versus-queue mismatch sneaks in.
Rich Values: Flags That Return More Than True or False
Flags don't have to be boolean. A closure can return a string, and Feature::active() treats anything except literal false as active. This turns a flag into a lightweight A/B test:
use Illuminate\Support\Arr;
Feature::define('purchase-button', fn () => Arr::random([
'blue-sapphire',
'seafoam-green',
'tart-orange',
]));
Retrieve the resolved value instead of just checking active state:
$color = Feature::value('purchase-button'); // 'seafoam-green'
Because the value is sticky per scope, each user keeps their assigned variant across requests, which is exactly what you want for a clean A/B test. No separate experiment table, no cookie juggling. And you can pull several at once with Feature::values(['billing-v2', 'purchase-button']) or grab everything defined for the current scope with Feature::all(), which is handy for passing the whole flag set to your frontend in one payload.
One caveat on Feature::all(): class-based features that haven't been checked yet during the request won't appear, because Pennant only knows about them once they're resolved. If you're building a flag dashboard or hydrating an Inertia page with every flag, be aware some may be missing until they're touched.
Pennant and Route Metadata
Last week's post on route metadata gated a whole route group behind a feature flag using one middleware that read a feature key off the route. Pennant is the other half of that pattern, so here's the complete version.
Tag the routes with metadata:
Route::metadata(['feature' => 'billing-v2'])
->prefix('billing')
->group(function () {
Route::get('/', [BillingController::class, 'index']);
Route::resource('invoices', InvoiceController::class);
});
Then one middleware reads the tag and asks Pennant:
use Laravel\Pennant\Feature;
public function handle(Request $request, Closure $next): Response
{
$flag = $request->route()->getMetadata('feature');
if ($flag && ! Feature::active($flag)) {
abort(404);
}
return $next($request);
}
Register it on the web group, and every flagged route is gated with zero per-route boilerplate. Deleting the rollout is deleting one metadata line. This is the payoff of the two features together: route metadata declares which flag guards a route, Pennant decides whether it's on, and the middleware is the five lines that connect them. It's a cleaner separation than scattering Feature::active() checks through your controllers.
Testing Feature Flags
Flags that change behavior need tests for both branches, and Pennant makes forcing a flag's state trivial. In a test, redefine or activate the flag before the assertion:
use Laravel\Pennant\Feature;
it('shows the new checkout when the flag is on', function () {
Feature::define('new-checkout', fn () => true);
$this->actingAs($this->user)
->get('/checkout')
->assertViewIs('checkout.new');
});
it('shows the legacy checkout when the flag is off', function () {
Feature::define('new-checkout', fn () => false);
$this->actingAs($this->user)
->get('/checkout')
->assertViewIs('checkout.legacy');
});
Because your test suite should use the array driver, nothing persists between tests and each one starts clean. Redefining the flag at the top of a test overrides whatever your service provider set, so you control exactly which branch runs. This is also why the sticky-storage behavior never bites you in tests: the array driver forgets everything after each request.
When Pennant Is the Wrong Tool
Honest boundaries, because not every toggle needs a flag package.
If a value never changes per user and never rolls out gradually, it's config, not a feature flag. config('services.stripe.enabled') doesn't belong in Pennant. Pennant earns its place when a toggle varies by scope or rolls out incrementally, not when it's a global on/off switch you set once.
If you need flag changes managed by non-engineers through a dashboard, audit logs, scheduled rollouts, and multi-environment targeting, a hosted service like LaunchDarkly does things Pennant doesn't try to. Pennant is a library, not a product. It has no UI. For a lot of teams that's the point, but if your product managers need to flip flags themselves, Pennant alone won't get you there without building the dashboard yourself.
And if you're reaching for flags to manage long-lived branches, the flag is treating a symptom. Feature flags pair best with trunk-based development and small, self-contained changes, where each push is shippable and the flag just controls visibility. A flag wrapped around a six-month branch is a merge conflict with extra steps.
For the common case, though, incrementally rolling out a feature to real users on your own infrastructure with no monthly bill, Pennant is hard to beat.
FAQ
Does changing a Pennant flag's closure update existing users?
No, and this catches nearly everyone. With the database driver, a flag resolves once per scope and stores the result. Editing the closure doesn't re-evaluate users who were already resolved. Run php artisan pennant:purge {flag} to clear stored values and force re-resolution against the new logic.
Why does my feature flag return false inside a queued job?
Because the scope is null. Jobs, Artisan commands, and unauthenticated routes have no authenticated user, so a closure that type-hints User (without allowing null) gets skipped and Pennant returns false. Pass the scope explicitly with Feature::for($user)->active(...), or make the closure accept a nullable scope.
What's the difference between Pennant's array and database drivers?
The database driver persists resolved values in the features table, so they survive across requests and deployments. That's the production default. The array driver keeps values in memory for one request only, which is what you want in tests so nothing leaks between them.
Can Pennant do percentage-based rollouts?
Yes. Use Illuminate\Support\Lottery inside the closure to activate a flag for a percentage of scopes. But be careful when raising the percentage: purging with Lottery re-rolls everyone, so users from the original rollout can lose the feature. For rollouts that only grow, use a deterministic hash bucket instead (covered in the rollout section), which keeps every previously included user in as the percentage rises.
Do I need a separate service like LaunchDarkly?
Not for most Laravel apps. Pennant covers scoped flags, rich values, and gradual rollouts natively with no external dependency. Reach for a hosted service when you need a non-engineer-facing dashboard, audit trails, or complex multi-environment targeting that Pennant deliberately leaves out.
Wrapping Up
Pennant is small enough to learn in an afternoon and useful enough to keep forever. The API is the easy part. The two things that actually matter are the ones tutorials skip: values are sticky per scope until you purge them, and a null scope in a job or command silently returns false. Get those two into your muscle memory and everything else is just Feature::active().
Start by replacing your ugliest .env boolean with a real flag. Once the first one's in and you've watched a rollout behave, you'll wonder why the config file ever held that logic.
About Hafiz
Senior Full Stack Developer. I build production software with Laravel, Filament, Vue, and AI integrations, and write about the real decisions behind shipping it.
Get in touch →Get web development tips via email
Join 50+ developers • No spam • Unsubscribe anytime
Related Articles
Squashing Laravel Migrations: 5 Ways schema:dump --prune Bites a Real Team
schema:dump --prune turns hundreds of migrations into one file. I reproduced the...
Laravel Mercure Broadcasting: Real-Time Without a WebSocket Server (and 3 Setup Traps)
Laravel 13.32 ships a Mercure broadcast driver. I built presence and encrypted c...
Migrate Laravel Uploads From Local Disk to S3 With Zero Downtime: Read-Through Disks and moveToDisk
Laravel 13.26 added read-through disks and 13.32 added moveToDisk. Together they...