Laravel Framework

Laravel Pennant & Feature Flags

15 min Lesson 39 of 45

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:

  1. Define feature "new-editor" that starts at 0%
  2. Create admin route to increase rollout percentage
  3. Ensure consistent assignment (same users always get it)
  4. Track which users have the feature in database
  5. Display rollout percentage in admin dashboard
  6. 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:

  1. Define feature "cta-test" with 3 variants: control, variant-a, variant-b
  2. Split traffic evenly (33% each)
  3. Show different button text/color per variant
  4. Track clicks with variant information
  5. Create analytics page showing conversion rate per variant
  6. 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:

  1. Define "team-collaboration" feature scoped to User + Team
  2. Feature active only if: team is premium AND user is team member
  3. Create middleware to check feature before accessing routes
  4. Show feature status in team settings
  5. Allow team admins to request feature access
  6. 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.