Laravel Pennant & Feature Flags
Laravel Pennant provides a simple, lightweight feature flag system that allows you to gradually roll out new features, perform A/B testing, and manage feature access for different user segments. Feature flags enable you to deploy code without immediately exposing it to all users, reducing risk and enabling data-driven development.
Understanding Feature Flags
Feature flags (also called feature toggles) allow you to enable or disable features at runtime without deploying new code:
Feature Flag Use Cases:
- Gradual Rollouts: Release features to a small percentage of users first
- A/B Testing: Test different versions of features with different user groups
- Beta Features: Allow premium or beta users to access experimental features
- Emergency Kill Switch: Quickly disable problematic features without redeploying
- Canary Releases: Test features in production with internal users before public launch
Installing Pennant
Install Laravel Pennant and set up the database storage:
// Install Pennant
composer require laravel/pennant
// Publish configuration
php artisan vendor:publish --provider="Laravel\Pennant\PennantServiceProvider"
// Create features table
php artisan pennant:table
php artisan migrate
// Configuration (config/pennant.php)
return [
'default' => env('PENNANT_STORE', 'database'),
'stores' => [
'database' => [
'driver' => 'database',
'connection' => null,
],
'array' => [
'driver' => 'array',
],
],
'middleware' => [
'web' => [
// Middleware to run for web requests
],
],
];
Defining Features
Define features in your AppServiceProvider or a dedicated FeatureServiceProvider:
// app/Providers/AppServiceProvider.php
use Laravel\Pennant\Feature;
use Illuminate\Support\Lottery;
public function boot(): void
{
// Simple boolean feature
Feature::define('dark-mode', fn () => true);
// User-based feature
Feature::define('new-dashboard', fn (User $user) => $user->isAdmin());
// Percentage-based rollout (50% of users)
Feature::define('beta-search', fn (User $user) => Lottery::odds(1, 2));
// Gradual rollout by user ID (consistent for same user)
Feature::define('new-ui', function (User $user) {
// 10% rollout based on user ID modulo
return $user->id % 10 === 0;
});
// Premium user feature
Feature::define('advanced-analytics', function (User $user) {
return $user->subscription?->isPremium() ?? false;
});
// Time-based feature
Feature::define('holiday-theme', function () {
return now()->between(
now()->parse('December 1'),
now()->parse('January 1')
);
});
// Multi-condition feature
Feature::define('ai-assistant', function (User $user) {
// Only for verified, premium users in specific countries
return $user->hasVerifiedEmail()
&& $user->isPremium()
&& in_array($user->country, ['US', 'CA', 'GB']);
});
// Feature with default value
Feature::define('chat-support', fn (?User $user) => $user?->isActive() ?? false);
}
Checking Features
Check if a feature is active in various parts of your application:
// In controllers
use Laravel\Pennant\Feature;
public function index()
{
// Check for current user
if (Feature::active('new-dashboard')) {
return view('dashboard.new');
}
return view('dashboard.classic');
}
// Check for specific user
$user = User::find(1);
if (Feature::for($user)->active('beta-search')) {
// Show beta search
}
// Check inactive
if (Feature::inactive('new-dashboard')) {
// Feature is off
}
// Get all active features for user
$features = Feature::all();
// ['new-dashboard' => true, 'beta-search' => false, ...]
// Check multiple features
$results = Feature::values(['new-dashboard', 'beta-search', 'dark-mode']);
// When callback - only execute if active
Feature::when('new-dashboard',
fn () => $this->showNewDashboard(),
fn () => $this->showClassicDashboard()
);
// In routes
Route::get('/dashboard', [DashboardController::class, 'index'])
->middleware('feature:new-dashboard');
// In Blade templates
@feature('new-dashboard')
<div class="new-dashboard">
<!-- New dashboard UI -->
</div>
@else
<div class="classic-dashboard">
<!-- Classic dashboard UI -->
</div>
@endfeature
// Check for guest users
@feature('public-api')
<a href="/api/docs">API Documentation</a>
@endfeature
Performance: Pennant caches feature values during the request lifecycle. The same feature check on the same scope will only execute the closure once, making feature checks very efficient.
Class-Based Features
For complex feature logic, create dedicated feature classes:
// Create feature class
php artisan pennant:feature NewDashboard
// app/Features/NewDashboard.php
namespace App\Features;
use Illuminate\Support\Lottery;
class NewDashboard
{
/**
* Resolve the feature's initial value.
*/
public function resolve(User $user): mixed
{
// Complex feature logic
if ($user->isAdmin()) {
return true;
}
if ($user->isBetaTester()) {
return true;
}
// 10% rollout for regular users
return Lottery::odds(1, 10)->choose();
}
/**
* Serialize the feature value for storage.
*/
public function serialize(mixed $value): mixed
{
return $value;
}
}
// Register class-based feature
// app/Providers/AppServiceProvider.php
use App\Features\NewDashboard;
use Laravel\Pennant\Feature;
public function boot(): void
{
Feature::define(NewDashboard::class);
}
// Check the feature
use App\Features\NewDashboard;
if (Feature::active(NewDashboard::class)) {
// New dashboard is active
}
// In Blade
@feature(App\Features\NewDashboard::class)
<!-- New dashboard -->
@endfeature
Scoping Features
Features can be scoped to different entities, not just users:
// Define team-scoped feature
Feature::define('team-chat', fn (Team $team) => $team->plan === 'premium');
// Check for specific team
$team = Team::find(1);
if (Feature::for($team)->active('team-chat')) {
// Team has chat feature
}
// Multiple scopes (user + team)
Feature::define('advanced-reports', function (User $user, Team $team) {
return $team->isPremium() && $user->hasRole('admin');
});
// Check with multiple scopes
if (Feature::for([$user, $team])->active('advanced-reports')) {
// Both user and team satisfy conditions
}
// Anonymous scope (no authentication required)
Feature::define('maintenance-mode', fn () => Cache::get('maintenance', false));
if (Feature::active('maintenance-mode')) {
// Site is in maintenance mode
}
// Scope to any model
Feature::define('premium-content', fn (Subscription $subscription) =>
$subscription->isActive() && $subscription->tier === 'premium'
);
Manually Activating Features
Programmatically activate or deactivate features for specific scopes:
use Laravel\Pennant\Feature;
// Activate for current user
Feature::activate('new-dashboard');
// Deactivate for current user
Feature::deactivate('new-dashboard');
// Activate for specific user
$user = User::find(1);
Feature::for($user)->activate('beta-search');
// Activate for multiple users
$users = User::where('is_beta_tester', true)->get();
Feature::for($users)->activate('beta-search');
// Activate multiple features
Feature::activateForEveryone(['dark-mode', 'new-ui']);
// Deactivate for everyone
Feature::deactivateForEveryone('broken-feature');
// Forget feature value (will re-evaluate on next check)
Feature::forget('new-dashboard');
// Purge all stored values for a feature
Feature::purge('deprecated-feature');
// In admin panel
public function toggleFeature(Request $request)
{
$validated = $request->validate([
'feature' => 'required|string',
'user_id' => 'required|exists:users,id',
'active' => 'required|boolean',
]);
$user = User::find($validated['user_id']);
if ($validated['active']) {
Feature::for($user)->activate($validated['feature']);
} else {
Feature::for($user)->deactivate($validated['feature']);
}
return response()->json(['message' => 'Feature updated']);
}
Manual Activation: Manually activating/deactivating features overrides the feature's resolve logic. These values are stored in the database and will persist until explicitly changed or purged.
A/B Testing with Pennant
Use feature flags to implement A/B testing:
// Define A/B test variants
Feature::define('checkout-flow', function (User $user) {
// Consistent assignment based on user ID
$variants = ['variant-a', 'variant-b', 'control'];
return $variants[$user->id % 3];
});
// In controller
public function checkout()
{
$variant = Feature::value('checkout-flow');
return match($variant) {
'variant-a' => view('checkout.variant-a'),
'variant-b' => view('checkout.variant-b'),
default => view('checkout.control'),
};
}
// Track conversion
public function complete(Request $request)
{
$variant = Feature::value('checkout-flow');
// Log conversion with variant
Analytics::track('checkout_complete', [
'variant' => $variant,
'user_id' => auth()->id(),
'revenue' => $request->total,
]);
// Complete order...
}
// Multi-variant testing (more than 2 variants)
Feature::define('pricing-page', function (User $user) {
return [
'layout' => Lottery::odds(1, 3)->winner(fn () => 'grid')
->loser(fn () => 'list')
->choose(),
'button_color' => collect(['green', 'blue', 'red'])
->random(),
];
});
// In view
@php
$config = Feature::value('pricing-page');
@endphp
<div class="pricing-{{ $config['layout'] }}">
<button class="btn-{{ $config['button_color'] }}">
Subscribe Now
</button>
</div>
Feature Flag Dashboard
Build an admin dashboard to manage features:
// app/Http/Controllers/Admin/FeatureFlagController.php
namespace App\Http\Controllers\Admin;
use Illuminate\Http\Request;
use Laravel\Pennant\Feature;
class FeatureFlagController extends Controller
{
public function index()
{
// Get all defined features
$features = collect([
'new-dashboard',
'beta-search',
'advanced-analytics',
'dark-mode',
])->map(function ($feature) {
return [
'name' => $feature,
'active_count' => $this->getActiveCount($feature),
'total_count' => User::count(),
];
});
return view('admin.features.index', compact('features'));
}
public function users(string $feature)
{
// Get users with this feature active
$users = User::query()
->whereHas('features', function ($query) use ($feature) {
$query->where('name', $feature)
->where('value', 'true');
})
->paginate(50);
return view('admin.features.users', compact('feature', 'users'));
}
public function toggle(Request $request, string $feature)
{
$validated = $request->validate([
'user_ids' => 'required|array',
'user_ids.*' => 'exists:users,id',
'active' => 'required|boolean',
]);
$users = User::findMany($validated['user_ids']);
if ($validated['active']) {
Feature::for($users)->activate($feature);
} else {
Feature::for($users)->deactivate($feature);
}
return back()->with('success', "Feature {$feature} updated for " . count($users) . " users");
}
public function rollout(Request $request, string $feature)
{
$validated = $request->validate([
'percentage' => 'required|integer|min:0|max:100',
]);
// Calculate how many users should have feature
$totalUsers = User::count();
$targetCount = ($totalUsers * $validated['percentage']) / 100;
// Get current active count
$currentCount = $this->getActiveCount($feature);
if ($currentCount < $targetCount) {
// Activate for more users
$needed = $targetCount - $currentCount;
$users = User::whereDoesntHave('features', function ($query) use ($feature) {
$query->where('name', $feature);
})->inRandomOrder()->limit($needed)->get();
Feature::for($users)->activate($feature);
} else {
// Deactivate for some users
$excess = $currentCount - $targetCount;
$users = User::whereHas('features', function ($query) use ($feature) {
$query->where('name', $feature)
->where('value', 'true');
})->inRandomOrder()->limit($excess)->get();
Feature::for($users)->deactivate($feature);
}
return back()->with('success', "Feature {$feature} rolled out to {$validated['percentage']}%");
}
private function getActiveCount(string $feature): int
{
return User::whereHas('features', function ($query) use ($feature) {
$query->where('name', $feature)
->where('value', 'true');
})->count();
}
}
Exercise 1: Implement Gradual Feature Rollout
Create a new feature with gradual rollout:
- Define feature "new-editor" that starts at 0%
- Create admin route to increase rollout percentage
- Ensure consistent assignment (same users always get it)
- Track which users have the feature in database
- Display rollout percentage in admin dashboard
- Add ability to force-enable for specific beta testers
Exercise 2: Build A/B Test System
Create an A/B test for a call-to-action button:
- Define feature "cta-test" with 3 variants: control, variant-a, variant-b
- Split traffic evenly (33% each)
- Show different button text/color per variant
- Track clicks with variant information
- Create analytics page showing conversion rate per variant
- Add ability to declare a winner and roll out to 100%
Exercise 3: Multi-Scope Feature System
Create a feature that works with multiple scopes:
- Define "team-collaboration" feature scoped to User + Team
- Feature active only if: team is premium AND user is team member
- Create middleware to check feature before accessing routes
- Show feature status in team settings
- Allow team admins to request feature access
- Build admin approval workflow
Summary
In this lesson, you mastered Laravel Pennant for feature flag management. You learned how to install and configure Pennant, define simple and complex features, check feature status throughout your application, create class-based features for complex logic, scope features to different entities, manually activate features, implement A/B testing, and build feature management dashboards. Feature flags are essential for modern application development, enabling risk-free deployments, data-driven decisions, and better user experiences.
In the next lesson, we'll explore performance optimization techniques and best practices for production Laravel applications.