Laravel Pennant: Feature Flags Done Right
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:
- Define features with closures or classes
- Use
Lotteryfor percentage rollouts - Purge when changing rollout logic
- Eager load to avoid N+1
- Always clean up old flags — feature flag debt is real