DEV Community

Cover image for Laravel Pennant: The Complete Feature Flags Guide
Hafiz
Hafiz

Posted on • Originally published at hafiz.dev

Laravel Pennant: The Complete Feature Flags Guide

Originally published at hafiz.dev


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
Enter fullscreen mode Exit fullscreen mode

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))
    );
}
Enter fullscreen mode Exit fullscreen mode

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');
Enter fullscreen mode Exit fullscreen mode

Feature::active() checks against the current user. To check a specific scope, use for():

if (Feature::for($user)->active('new-checkout')) {
    // ...
}
Enter fullscreen mode Exit fullscreen mode

In Blade, there's a directive that reads cleaner than an inline check:

@feature('new-checkout')
    <x-checkout.new />
@else
    <x-checkout.legacy />
@endfeature
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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),
});
Enter fullscreen mode Exit fullscreen mode

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 also why widening to 25% later means editing the odds and running pennant:purge so unresolved and previously-resolved users alike get evaluated against the new number.

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.

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'])
);
Enter fullscreen mode Exit fullscreen mode
if (Feature::for($request->user()->currentTeam)->active('billing-v2')) {
    // ...
}
Enter fullscreen mode Exit fullscreen mode

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')) {
    // ...
}
Enter fullscreen mode Exit fullscreen mode

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
);
Enter fullscreen mode Exit fullscreen mode

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',
]));
Enter fullscreen mode Exit fullscreen mode

Retrieve the resolved value instead of just checking active state:

$color = Feature::value('purchase-button'); // 'seafoam-green'
Enter fullscreen mode Exit fullscreen mode

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);
    });
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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');
});
Enter fullscreen mode Exit fullscreen mode

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. Remember that values are sticky once resolved, so raising the percentage later requires a pennant:purge for existing users to be re-evaluated.

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.

Top comments (0)