Implements Laravel Pennant feature flags: define via closures/classes, check activation/scoping, get rich A/B testing values, enable gradual rollouts.
npx claudepluginhub iserter/laravel-claude-agents --plugin laravel-claude-agentsThis skill uses the workspace's default tool permissions.
```bash
Generates design tokens/docs from CSS/Tailwind/styled-components codebases, audits visual consistency across 10 dimensions, detects AI slop in UI.
Records polished WebM UI demo videos of web apps using Playwright with cursor overlay, natural pacing, and three-phase scripting. Activates for demo, walkthrough, screen recording, or tutorial requests.
Delivers idiomatic Kotlin patterns for null safety, immutability, sealed classes, coroutines, Flows, extensions, DSL builders, and Gradle DSL. Use when writing, reviewing, refactoring, or designing Kotlin code.
composer require laravel/pennant
php artisan vendor:publish --provider="Laravel\Pennant\PennantServiceProvider"
php artisan migrate
<?php
// app/Providers/AppServiceProvider.php
use Illuminate\Support\Lottery;
use Laravel\Pennant\Feature;
public function boot(): void
{
// Simple boolean feature
Feature::define('new-dashboard', function () {
return true;
});
// Scoped to the authenticated user
Feature::define('beta-access', function (User $user) {
return $user->is_beta_tester;
});
// Gradual rollout with lottery
Feature::define('redesigned-checkout', function (User $user) {
return Lottery::odds(1, 10); // 10% of users
});
// Based on user attributes
Feature::define('premium-features', function (User $user) {
return $user->subscribed('premium');
});
}
php artisan pennant:feature NewOnboarding
<?php
namespace App\Features;
use App\Models\User;
use Illuminate\Support\Lottery;
class NewOnboarding
{
// Resolve the feature's initial value
public function resolve(User $user): mixed
{
return match (true) {
$user->isInternal() => true,
$user->created_at->isAfter('2025-01-01') => true,
default => Lottery::odds(1, 5),
};
}
}
// Usage with class-based features
Feature::active(NewOnboarding::class); // for the authenticated user
Feature::for($user)->active(NewOnboarding::class);
use Laravel\Pennant\Feature;
// ✅ Check if active
if (Feature::active('new-dashboard')) {
// Show new dashboard
}
// ✅ Check if inactive
if (Feature::inactive('new-dashboard')) {
// Show old dashboard
}
// ✅ Check multiple features
if (Feature::allAreActive(['new-dashboard', 'beta-access'])) {
// All features are active
}
if (Feature::someAreActive(['feature-a', 'feature-b'])) {
// At least one is active
}
if (Feature::allAreInactive(['deprecated-feature', 'old-ui'])) {
// None of these are active
}
if (Feature::someAreInactive(['feature-a', 'feature-b'])) {
// At least one is inactive
}
// Get the resolved value (may not be boolean)
$value = Feature::value('purchase-button');
// Get values for multiple features
$values = Feature::values(['feature-a', 'feature-b']);
// ['feature-a' => true, 'feature-b' => 'variant-b']
// Check for the currently authenticated user (default)
Feature::active('beta-access');
// Check for a specific user
Feature::for($user)->active('beta-access');
// Check for multiple users
$users = User::where('role', 'admin')->get();
Feature::for($users)->active('beta-access');
// Define a team-scoped feature
Feature::define('team-billing-v2', function (Team $team) {
return $team->plan === 'enterprise';
});
// Check for a specific team
Feature::for($team)->active('team-billing-v2');
// Define a feature with nullable scope (for guests)
Feature::define('maintenance-banner', function (User|null $user) {
return config('app.show_maintenance_banner');
});
// Check without authentication
Feature::for(null)->active('maintenance-banner');
// Define a feature with rich values
Feature::define('purchase-button', function (User $user) {
return Lottery::odds(1, 3)->choose(
fn () => 'blue-button', // 33%
fn () => 'green-button', // 67% (default)
);
});
// Alternative: deterministic assignment
Feature::define('purchase-button', function (User $user) {
return match ($user->id % 3) {
0 => 'blue-button',
1 => 'green-button',
2 => 'red-button',
};
});
{{-- Using rich values in views --}}
@php $variant = Feature::value('purchase-button') @endphp
@if ($variant === 'blue-button')
<button class="bg-blue-600 text-white">Buy Now</button>
@elseif ($variant === 'green-button')
<button class="bg-green-600 text-white">Buy Now</button>
@else
<button class="bg-red-600 text-white">Buy Now</button>
@endif
// ✅ Execute code based on feature state
Feature::when('new-dashboard',
fn () => $this->renderNewDashboard(),
fn () => $this->renderOldDashboard(),
);
// ✅ With rich values
Feature::when('purchase-button',
fn ($variant) => view('buttons.' . $variant),
fn () => view('buttons.default'),
);
// ✅ Unless (inverse)
Feature::unless('legacy-mode',
fn () => $this->useModernApi(),
fn () => $this->useLegacyApi(),
);
{{-- ✅ Basic feature check --}}
@feature('new-dashboard')
<x-new-dashboard :user="$user" />
@else
<x-legacy-dashboard :user="$user" />
@endfeature
{{-- ✅ Class-based feature --}}
@feature(App\Features\NewOnboarding::class)
<x-new-onboarding-wizard />
@endfeature
{{-- ✅ Combine with other directives --}}
@auth
@feature('premium-features')
<x-premium-sidebar />
@else
<x-standard-sidebar />
@endfeature
@endauth
use Laravel\Pennant\Middleware\EnsureFeaturesAreActive;
// In routes
Route::get('/new-dashboard', NewDashboardController::class)
->middleware(EnsureFeaturesAreActive::using('new-dashboard'));
// Multiple features required
Route::get('/beta', BetaController::class)
->middleware(EnsureFeaturesAreActive::using('beta-access', 'new-dashboard'));
// In route groups
Route::middleware([
'auth',
EnsureFeaturesAreActive::using('beta-access'),
])->group(function () {
Route::get('/beta/dashboard', [BetaController::class, 'dashboard']);
Route::get('/beta/settings', [BetaController::class, 'settings']);
});
// In AppServiceProvider
use Symfony\Component\HttpKernel\Exception\HttpException;
public function boot(): void
{
// Redirect when feature is inactive
EnsureFeaturesAreActive::whenInactive(
function (Request $request, array $features) {
return redirect()->route('dashboard')
->with('warning', 'This feature is not available.');
}
);
}
// Activate for a specific user
Feature::for($user)->activate('new-dashboard');
// Activate with a specific value
Feature::for($user)->activate('purchase-button', 'green-button');
// Activate for all users
Feature::activateForEveryone('new-dashboard');
// Activate for everyone with a value
Feature::activateForEveryone('purchase-button', 'blue-button');
// Deactivate for a specific user
Feature::for($user)->deactivate('new-dashboard');
// Deactivate for everyone
Feature::deactivateForEveryone('new-dashboard');
// Forget stored value (will be re-resolved next check)
Feature::for($user)->forget('new-dashboard');
// Purge all stored values for a feature
Feature::purge('new-dashboard');
// Purge all features
Feature::purge();
// Activate for a group of users
$betaUsers = User::where('is_beta_tester', true)->get();
foreach ($betaUsers as $user) {
Feature::for($user)->activate('new-dashboard');
}
// ✅ Eager load features to avoid repeated queries
Feature::for($user)->load(['new-dashboard', 'beta-access', 'premium-features']);
// ✅ Load all defined features
Feature::for($user)->loadAll();
// Then check without additional queries
if (Feature::active('new-dashboard')) { /* ... */ }
if (Feature::active('beta-access')) { /* ... */ }
// ✅ Check and store the initial value
Feature::active('new-dashboard'); // Resolves and stores
// ✅ Later, get the latest resolved value (ignoring stored)
$fresh = Feature::for($user)->value('new-dashboard');
// config/pennant.php
'default' => env('PENNANT_STORE', 'database'),
'stores' => [
'array' => [
'driver' => 'array',
],
'database' => [
'driver' => 'database',
'connection' => null,
'table' => 'features',
],
],
use Laravel\Pennant\Feature;
public function test_new_dashboard_is_shown_when_feature_active(): void
{
// Activate the feature for the test
Feature::activate('new-dashboard');
$response = $this->actingAs($this->user)
->get('/dashboard');
$response->assertSee('New Dashboard');
}
public function test_old_dashboard_is_shown_when_feature_inactive(): void
{
// Deactivate the feature for the test
Feature::deactivate('new-dashboard');
$response = $this->actingAs($this->user)
->get('/dashboard');
$response->assertSee('Classic Dashboard');
}
public function test_rich_value_determines_button_variant(): void
{
Feature::for($this->user)->activate('purchase-button', 'green-button');
$response = $this->actingAs($this->user)
->get('/shop');
$response->assertSee('bg-green-600');
}
public function test_feature_middleware_blocks_inactive_features(): void
{
Feature::deactivate('beta-access');
$response = $this->actingAs($this->user)
->get('/beta/dashboard');
$response->assertStatus(400);
}
public function test_gradual_rollout_is_consistent(): void
{
// Features are stored after first resolution, so they stay consistent
$firstCheck = Feature::for($this->user)->active('redesigned-checkout');
$secondCheck = Feature::for($this->user)->active('redesigned-checkout');
$this->assertEquals($firstCheck, $secondCheck);
}
// phpunit.xml or .env.testing
// PENNANT_STORE=array
// Or in test setup
protected function setUp(): void
{
parent::setUp();
Feature::store('array');
}
@feature Blade directive used in templatesEnsureFeaturesAreActive middleware guards feature-gated routesFeature::activate() / Feature::deactivate() for deterministic behavior