Laravel Pennant Feature Flags in Practice

Learn how to use Laravel Pennant feature flags for gradual rollouts, A/B tests, and safe deployments — class-based features, Blade directives, and Pest testing.

Steven Richardson
Steven Richardson
· 7 min read

You want to ship a half-built feature to your internal team, then slowly expand access to 1% of users, then 10%, then everyone. Without a third-party service. Laravel Pennant handles this — it's first-party, lightweight, and integrates cleanly with your existing auth and Eloquent models.

Setup and Configuration#

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

That creates a features table and a config/pennant.php config file. The default driver is database, which stores resolved flag values persistently. For tests or CI, switch to array in the config so nothing gets written to the database between test cases.

// config/pennant.php
'default' => env('PENNANT_DRIVER', 'database'),
# .env.testing
PENNANT_DRIVER=array

If you're upgrading an existing app, check the Laravel 12 to 13 upgrade guide — Pennant has shipped with the framework since Laravel 10 and the API has been stable.

Defining Features#

Closure-based features

Define features in AppServiceProvider::boot() using the Feature facade. The closure receives the current scope — almost always the authenticated user:

use App\Models\User;
use Illuminate\Support\Lottery;
use Laravel\Pennant\Feature;

Feature::define('new-api', fn (User $user) => match (true) {
    $user->isInternalTeamMember() => true,
    $user->isHighTrafficCustomer() => false,
    default => Lottery::odds(1 / 100),
});

The first time this flag is checked for a user, the result is stored. Subsequent checks within the same request use an in-memory cache; subsequent requests read from the database — so you never get a flickering flag mid-session.

Class-based features

For anything more than a few lines, use a class-based feature instead. It's cleaner, supports constructor injection, and Pennant auto-discovers it without any registration.

php artisan pennant:feature NewApi

This creates app/Features/NewApi.php:

<?php

namespace App\Features;

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

class NewApi
{
    public function __construct(
        private readonly SomeService $service,
    ) {}

    /**
     * Resolve the feature's initial value.
     */
    public function resolve(User $user): mixed
    {
        return match (true) {
            $user->isInternalTeamMember() => true,
            $user->isHighTrafficCustomer() => false,
            default => Lottery::odds(1 / 100),
        };
    }
}

By default the stored feature name is the fully-qualified class name. If you'd rather decouple the name from your namespace — useful when you rename or move a class — add the #[Name] PHP attribute:

use Laravel\Pennant\Attributes\Name;

#[Name('new-api')]
class NewApi
{
    // ...
}

This is the same attribute pattern used across Laravel 13's PHP attribute syntax for models, jobs, and commands.

Checking Flags Everywhere#

In controllers

use Laravel\Pennant\Feature;

public function index(Request $request): Response
{
    return Feature::active('new-api')
        ? $this->newApiResponse($request)
        : $this->legacyApiResponse($request);
}

Feature::active() checks against the authenticated user by default. You can also check multiple flags:

Feature::allAreActive(['new-api', 'beta-dashboard']); // both must be active
Feature::someAreActive(['new-api', 'beta-dashboard']); // at least one active
Feature::inactive('new-api'); // inverse check

In Blade

Pennant registers a @feature Blade directive out of the box:

@feature('new-api')
    <x-new-dashboard />
@endfeature

For a fallback, negate with a standard @if:

@feature('new-api')
    <x-new-dashboard />
@endfeature

@if(Feature::inactive('new-api'))
    <x-legacy-dashboard />
@endif

In middleware

Gate an entire route group behind a flag using EnsureFeaturesAreActive. Users without the feature get a 400 response by default — you can customise that to a redirect:

use Laravel\Pennant\Middleware\EnsureFeaturesAreActive;

Route::prefix('api/v2')
    ->middleware(EnsureFeaturesAreActive::using('new-api'))
    ->group(function () {
        Route::get('/users', [UserController::class, 'index']);
        Route::get('/orders', [OrderController::class, 'index']);
    });

This pairs well with the approach in fine-grained rate limiting on Laravel API routes — you can layer both middlewares on the same route group to protect a new API surface.

Customise the inactive response:

EnsureFeaturesAreActive::whenInactive(
    fn (Request $request, array $features) => redirect('/upgrade')
);

Rollout Strategies#

Lottery::odds() is the core tool for percentage rollouts. The first argument is the numerator, the second is the denominator:

// 1% rollout
Feature::define('new-checkout', Lottery::odds(1, 100));

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

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

Because Pennant stores the resolved value on first check, a user who lands in the active 1% stays there — they don't re-roll on every request.

For segment-based rollouts combining user attributes and percentage:

Feature::define('early-access', fn (User $user) => match (true) {
    $user->plan === 'enterprise' => true,          // all enterprise users
    $user->created_at->isAfter('2025-01-01') => Lottery::odds(1, 10), // 10% of new users
    default => false,
});

This pattern supports zero-downtime deployments and trunk-based development — merge incomplete features behind a flag, expand access incrementally, then flip the switch rather than managing long-lived branches.

Testing with Feature Flags#

Tests must be deterministic. Don't let Lottery or database state bleed into your assertions — override the feature definition directly in the test.

use Laravel\Pennant\Feature;

it('returns the new API response when the flag is active', function () {
    $user = User::factory()->create();

    Feature::define('new-api', fn () => true); // always active for this test

    $this->actingAs($user)
        ->getJson('/api/v2/users')
        ->assertOk()
        ->assertJsonStructure(['data' => [['id', 'name', 'email']]]);
});

it('returns the legacy response when the flag is inactive', function () {
    $user = User::factory()->create();

    Feature::define('new-api', fn () => false); // always inactive

    $this->actingAs($user)
        ->getJson('/api/v2/users')
        ->assertOk()
        ->assertJsonStructure(['users']);
});

You can also force a specific state on a specific scope:

Feature::activate('new-api', $user);      // force active for this user
Feature::deactivate('new-api', $user);    // force inactive for this user

When using the database driver in tests, remember to call Feature::flushCache() between tests if you're not using RefreshDatabase — stale in-memory cache from one test can silently affect the next. See Pest architecture testing for patterns around keeping your test suite clean and structured.

Gotchas and Edge Cases#

The array driver doesn't persist. It's per-request only. If you run Feature::activate('flag') in a test setup and then make an HTTP call via $this->get(), that's a new request — the activation won't carry over unless you're using RefreshDatabase with a real database. Set PENNANT_DRIVER=array in .env.testing and use Feature::define() to override inside the test itself.

Scope defaults to the authenticated user. If you check a flag before a user is authenticated, the scope is null. Pennant handles this gracefully with a nullable scope, but make sure your feature closure handles it:

Feature::define('maintenance-mode', fn (?User $user) => config('app.maintenance'));

Lottery re-rolls if the stored value is purged. If you call php artisan pennant:purge new-api, all resolved values are deleted and users re-roll on next check. Useful for a reset, dangerous if you do it mid-rollout by accident.

Stale flags accumulate. Once a feature is fully rolled out and the flag check is removed from code, purge it from the database:

# Purge a specific flag
php artisan pennant:purge new-api

# Purge everything except active flags
php artisan pennant:purge --except=beta-dashboard

Schedule a regular audit — features table rows for deleted flags are dead weight.

Wrapping Up#

Pennant fits cleanly into a modern Laravel workflow: define flags in a service provider or class, check them in controllers, Blade, and middleware, then override in tests. No external service, no SDK to maintain. For a broader view of where Pennant sits alongside other first-party tools, the complete Laravel developer toolchain for 2026 covers the full picture.

Next step after feature flags: pair them with proper monitoring. The Telescope vs Debugbar vs Pulse comparison covers which observability tool to reach for to watch which features are being hit in production.

FAQ#

How do I add feature flags to Laravel?

Install Laravel Pennant with composer require laravel/pennant, publish the config and migration with php artisan vendor:publish --provider="Laravel\Pennant\PennantServiceProvider", and run php artisan migrate. Then define features using the Feature::define() method in your AppServiceProvider::boot() method, or create class-based features with php artisan pennant:feature FeatureName. Pennant uses your database to store resolved flag values by default, so no external service is required.

What is Laravel Pennant?

Laravel Pennant is the official first-party feature flag package for Laravel, included with the framework since Laravel 10. It lets you toggle application features on or off per user, roll out changes to a percentage of your user base using Lottery::odds(), and gate routes or UI sections behind flags. Feature values are stored in a features database table after the first resolution, making each flag stable for the lifetime of a user's session and consistent across requests.

How do I do a gradual rollout in Laravel?

Use Lottery::odds() inside a Pennant feature definition. For example, Feature::define('new-checkout', Lottery::odds(1, 100)) activates the feature for approximately 1% of users. Because Pennant stores the resolved value on first check, a user who lands in the active cohort stays there on subsequent requests — they do not re-roll. You can combine user attributes with Lottery for segment-targeted rollouts, such as giving all enterprise users immediate access while rolling out to 10% of standard users.

Can I use Laravel Pennant without a database?

Yes. Pennant ships with an array driver that stores resolved feature values in memory for the duration of the current request only. Switch to it by setting PENNANT_DRIVER=array in your environment — this is recommended for test environments to keep tests fast and isolated. For production you almost always want the database driver so that flag state persists across requests and users get a consistent experience. You can also implement a custom driver if you want to back Pennant with Redis or an external feature flag service.

Steven Richardson
Steven Richardson

CTO at Digitonic. Writing about Laravel, architecture, and the craft of leading software teams from the west coast of Scotland.