Laravel Pennant: Feature Flags Done Right

· 6 min read

Deploying a feature to 100% of users on day one is gambling. Feature flags let you control who sees what, without redeploying code. Laravel Pennant is the first-party solution — simple, Eloquent-integrated, and built for exactly this.

Why Feature Flags?

Without feature flags:

1. Write code → 2. Deploy → 3. Hope nothing breaks → 4. Rollback if it does

With feature flags:

1. Write code → 2. Deploy (flag OFF) → 3. Enable for 5% → 4. Monitor → 5. Scale to 100%

Real-world scenarios:

  • Roll out a new checkout flow to 10% of paying customers
  • Enable beta features for internal team only
  • A/B test a pricing page
  • Kill switch: instantly disable a broken feature

Installation

composer require laravel/pennant

php artisan vendor:publish --provider="Laravel\Pennant\PennantServiceProvider"

php artisan migrate

This creates a features table to store resolved feature states.

Defining Features

Features are defined in your AppServiceProvider:

// app/Providers/AppServiceProvider.php

use Laravel\Pennant\Feature;
use App\Models\User;

public function boot(): void
{
    Feature::define('new-dashboard', function (User $user): bool {
        return $user->is_admin;
    });

    Feature::define('redesigned-checkout', function (User $user): bool {
        // Gradual rollout: 20% of users
        return $user->id % 5 === 0;
    });

    Feature::define('dark-mode', function (User $user): bool {
        return $user->created_at->isAfter('2026-01-01');
    });
}

The closure receives the scope (usually a User model) and returns true/false. Once resolved for a user, the result is stored in the database and won't change until you explicitly update it.

Checking Features

In Controllers

use Laravel\Pennant\Feature;

class DashboardController extends Controller
{
    public function index()
    {
        if (Feature::active('new-dashboard')) {
            return view('dashboard.v2');
        }

        return view('dashboard.v1');
    }
}

In Blade

@feature('new-dashboard')
    <x-dashboard-v2 />
@else
    <x-dashboard-v1 />
@endfeature

In Middleware

// routes/web.php
use Laravel\Pennant\Middleware\EnsureFeatureIsActive;

Route::get('/beta/reports', ReportController::class)
    ->middleware(EnsureFeatureIsActive::using('advanced-reports'));

If the feature is inactive, Pennant returns a 403 by default. You can customize this:

// AppServiceProvider
use Symfony\Component\HttpKernel\Exception\HttpException;

Feature::whenInactive('advanced-reports', function () {
    abort(404); // Hide completely instead of 403
});

Rich Feature Values

Features aren't limited to boolean. Return any value for A/B testing:

Feature::define('checkout-flow', function (User $user): string {
    return match (true) {
        $user->id % 3 === 0 => 'variant-a',
        $user->id % 3 === 1 => 'variant-b',
        default => 'control',
    };
});
$variant = Feature::value('checkout-flow');

return match ($variant) {
    'variant-a' => view('checkout.single-page'),
    'variant-b' => view('checkout.multi-step'),
    default => view('checkout.classic'),
};

Class-Based Features

For complex logic, use dedicated feature classes:

php artisan pennant:feature NewOnboardingFlow
// app/Features/NewOnboardingFlow.php

namespace App\Features;

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

class NewOnboardingFlow
{
    /**
     * Resolve the feature's initial value.
     */
    public function resolve(User $user): bool
    {
        // 25% lottery for new users, always on for admins
        if ($user->is_admin) {
            return true;
        }

        if ($user->created_at->isAfter(now()->subDays(7))) {
            return Lottery::odds(1, 4)->choose();
        }

        return false;
    }
}
use App\Features\NewOnboardingFlow;

if (Feature::active(NewOnboardingFlow::class)) {
    // New flow
}

Gradual Rollouts with Lottery

Lottery is the key to percentage-based rollouts:

use Illuminate\Support\Lottery;

Feature::define('new-search', function (User $user): bool {
    return Lottery::odds(1, 10)->choose(); // 10% chance
});

Important: Once resolved, the result is stored. The user won't randomly flip between states on each request. To expand the rollout:

// Activate for ALL users who haven't been resolved yet
Feature::activateForEveryone('new-search');

// Or selectively
Feature::for($user)->activate('new-search');

// Deactivate for specific user
Feature::for($user)->deactivate('new-search');

Purging & Re-evaluating

When you change rollout logic, old stored values remain. Purge to re-evaluate:

// Re-evaluate for all users
Feature::purge('new-search');

// Re-evaluate for specific user
Feature::for($user)->forget('new-search');
# Artisan command to purge
php artisan pennant:purge new-search

Scopes Beyond Users

Feature flags aren't limited to users. Check against teams, organizations, or any model:

Feature::define('advanced-analytics', function (Team $team): bool {
    return $team->plan === 'enterprise';
});

// Check with explicit scope
Feature::for($team)->active('advanced-analytics');

Nullable Scope (Global Features)

For features not tied to a specific user:

Feature::define('maintenance-banner', function (): bool {
    return config('app.maintenance_banner');
});

// Check without scope
Feature::active('maintenance-banner');

Eager Loading for Performance

Avoid N+1 queries when checking features for multiple users:

// Load features for all users at once
Feature::for($users)->loadAll();

// Load specific features
Feature::for($users)->load(['new-dashboard', 'dark-mode']);

In a listing page:

$users = User::paginate(20);

Feature::for($users)->load(['premium-badge']);

foreach ($users as $user) {
    // No additional query — already loaded
    $hasBadge = Feature::for($user)->active('premium-badge');
}

Testing

Pennant makes testing clean:

use Laravel\Pennant\Feature;

public function test_new_dashboard_is_shown_when_feature_active(): void
{
    Feature::activate('new-dashboard');

    $response = $this->actingAs($this->user)
        ->get('/dashboard');

    $response->assertViewIs('dashboard.v2');
}

public function test_old_dashboard_is_shown_when_feature_inactive(): void
{
    Feature::deactivate('new-dashboard');

    $response = $this->actingAs($this->user)
        ->get('/dashboard');

    $response->assertViewIs('dashboard.v1');
}

public function test_checkout_ab_test(): void
{
    Feature::define('checkout-flow', fn () => 'variant-a');

    $response = $this->get('/checkout');

    $response->assertViewIs('checkout.single-page');
}

Always use Feature::activate() / Feature::deactivate() in tests — don't rely on the closure logic.

Drivers: Database vs Array

By default, Pennant uses the database driver. For testing or stateless apps:

// config/pennant.php
'default' => env('PENNANT_STORE', 'database'),

'stores' => [
    'database' => [
        'driver' => 'database',
        'connection' => null,
        'table' => 'features',
    ],
    'array' => [
        'driver' => 'array',
    ],
],

In phpunit.xml:

<env name="PENNANT_STORE" value="array"/>

Production Workflow

A real-world deployment flow:

Day 1: Deploy with flag OFF
        Feature::define('new-billing', fn() => false);

Day 2: Enable for internal team
        Feature::for($internalUsers)->activate('new-billing');

Day 3: Enable for 10% of users
        Feature::purge('new-billing');
        // Update definition to Lottery::odds(1, 10)

Day 5: Monitoring looks good → 50%
        Feature::purge('new-billing');
        // Update to Lottery::odds(1, 2)

Day 7: Full rollout
        Feature::activateForEveryone('new-billing');

Day 14: Remove flag, clean up code
        // Delete feature definition and all @feature checks
        php artisan pennant:purge new-billing

Conclusion

Feature flags transform deployments from "all or nothing" to gradual, measurable releases. Laravel Pennant gives you this with zero external dependencies — just your database and a few lines of code.

Key takeaways:

  1. Define features with closures or classes
  2. Use Lottery for percentage rollouts
  3. Purge when changing rollout logic
  4. Eager load to avoid N+1
  5. Always clean up old flags — feature flag debt is real

Comments