Gradual rollouts, A/B testing, beta user groups, kill switches for broken features, and per-tenant flags in a multi-tenant app — Laravel Pennant ships everything your team has been paying LaunchDarkly for, in a package that already knows your users, your models, and your database.
Feature flags are one of those concepts that sounds optional until you’ve shipped a broken feature to production at 11pm and spent two hours rolling back a deployment. After that, feature flags become non-negotiable.
The common response: sign up for LaunchDarkly, Split, or Statsig. They’re excellent tools. They’re also $300–$600/month for a growing SaaS, require SDK integration, have their own user model that’s separate from yours, and add a third-party dependency to every feature check in your application.
Laravel Pennant ships everything you need, integrates directly with your Eloquent models and database, and costs exactly $0.
This is the complete guide.
What Feature Flags Actually Solve
Before the code, the problems feature flags solve — because understanding the use cases determines how you implement them.
Decouple deployment from release. Deploy code to production with the feature disabled. Enable it for a subset of users. Verify it works. Enable for everyone. Roll back by disabling the flag — no deployment required.
Gradual rollouts. Enable a feature for 1% of users. Then 10%. Then 50%. Each step validates the feature at scale before the full release.
A/B testing. Show 50% of users the old checkout flow, 50% the new one. Measure which converts better.
Beta programs. Enable features for specific users or user groups before general availability.
Kill switches. Disable a broken feature in production without a deployment. The most valuable use case of all.
Per-tenant flags. In a multi-tenant app, enable features for specific customers — before enterprise tier gates, custom agreements, or pilot programs.
Pennant handles all of these with a clean, Laravel-native API.
Installation
composer require laravel/pennant
php artisan vendor:publish --provider="Laravel\Pennant\PennantServiceProvider"
php artisan migrate
This creates a features table with three columns: name (the feature name), scope (who the flag is scoped to), and value (the resolved value).
Defining Features: Three Approaches
Approach 1: Closure-Based Definition
The simplest approach — define the feature and its resolution logic inline:
// app/Providers/AppServiceProvider.php
use Laravel\Pennant\Feature;
use Illuminate\Support\Lottery;
public function boot(): void
{
// Simple boolean flag — on or off for everyone
Feature::define('maintenance-mode', fn() => false);
// User-based flag — logic decides per user
Feature::define('new-dashboard', fn(User $user) =>
$user->created_at->isAfter(now()->subDays(30)) // only new users
);
// Beta tester flag
Feature::define('ai-search', fn(User $user) =>
$user->is_beta_tester || $user->hasRole('admin')
);
// Lottery-based gradual rollout — 20% of users
Feature::define('new-checkout', Lottery::odds(1, 5));
// Rich value instead of boolean — A/B test with variant tracking
Feature::define('checkout-button-color', fn(User $user) =>
$user->id % 2 === 0 ? 'blue' : 'green'
);
}
Approach 2: Class-Based Feature Definitions
For complex flag logic, a dedicated class keeps things organised:
// app/Features/NewCheckoutFlow.php
<?php
namespace App\Features;
use App\Models\User;
use Illuminate\Support\Lottery;
class NewCheckoutFlow
{
public function resolve(User $user): bool
{
// Internal team always gets the new flow
if ($user->hasRole('admin') || $user->hasRole('developer')) {
return true;
}
// Beta testers always get it
if ($user->is_beta_tester) {
return true;
}
// 10% gradual rollout for everyone else
return Lottery::odds(1, 10)->choose();
}
}
// Register in AppServiceProvider
Feature::define(NewCheckoutFlow::class);
// Check using the class
if (Feature::active(NewCheckoutFlow::class)) {
// show new checkout
}
Approach 3: The HasFeatures Trait on Models
Add the HasFeatures trait to any model to check flags directly on model instances:
// app/Models/User.php
use Laravel\Pennant\Concerns\HasFeatures;
class User extends Authenticatable
{
use HasFeatures;
}
// Check flags directly on the user instance
$user->features()->active('new-dashboard');
$user->features()->value('checkout-button-color');
Checking Features: Every Context
In Controllers
use Laravel\Pennant\Feature;
class CheckoutController extends Controller
{
public function show(): View
{
if (Feature::active('new-checkout-flow')) {
return view('checkout.new')
}
return view('checkout.legacy')
}
// Check for a specific scope (not the current user)
public function showForUser(User $user): View
{
if (Feature::for($user)->active('new-checkout-flow')) {
return view('checkout.new');
}
return view('checkout.legacy');
}
}
In Blade Templates
Pennant ships with first-party Blade directives:
{{-- Simple boolean check --}}
@feature('new-dashboard')
<x-dashboard.new />
@else
<x-dashboard.legacy />
@endfeature
{{-- Check a feature value for A/B testing --}}
@php $buttonColor = Feature::value('checkout-button-color') @endphp
<button class="btn-{{ $buttonColor }}">Complete Purchase</button>
{{-- Negate check --}}
@unlessfeature('new-checkout-flow')
<div class="legacy-banner">
You're on the classic checkout.
<a href="/checkout/preview">Try the new one</a>
</div>
@endunlessfeature
In Middleware
// app/Http/Middleware/RequireFeatureFlag.php
class RequireFeatureFlag
{
public function handle(Request $request, Closure $next, string $feature): Response
{
if (!Feature::active($feature)) {
abort(403, 'This feature is not available for your account.');
}
return $next($request);
}
}
// routes/api.php
Route::middleware(['auth:sanctum', 'feature:ai-search'])->group(function () {
Route::post('/search/semantic', [SearchController::class, 'semantic']);
});
In Jobs and Commands
class ProcessWithNewAlgorithm implements ShouldQueue
{
public function handle(): void
{
// Flags work in jobs — scope to the relevant user
if (Feature::for($this->user)->active('new-processing-algorithm')) {
$this->runNewAlgorithm();
} else {
$this->runLegacyAlgorithm();
}
}
}
The Lottery Class: Gradual Rollouts
Lottery::odds() is the core tool for percentage-based rollouts. The key behaviour: once resolved for a user, the result is stored in the database. A user who gets true on their first request always gets true. No flickering.
use Illuminate\Support\Lottery;
// 10% of users — odds(1, 10)
Feature::define('new-feature', Lottery::odds(1, 10));
// 25% of users — odds(1, 4)
Feature::define('new-feature', Lottery::odds(1, 4));
// 50/50 A/B test
Feature::define('new-feature', Lottery::odds(1, 2));
// Increasing rollout — combine with manual override
Feature::define('new-checkout', function (User $user) {
if ($user->is_beta_tester) return true; // 100% of beta testers
if ($user->hasRole('admin')) return true; // 100% of admins
return Lottery::odds(1, 10)->choose(); // 10% of everyone else
});
Increasing the Rollout Percentage
Pennant stores the resolved value per user in the features table. If a user was resolved to false at 10%, they stay false even after you increase to 50% — unless you clear their stored value.
// Artisan: clear stored values to re-resolve with new odds
php artisan pennant:purge new-checkout
// Or purge and immediately activate for everyone
php artisan pennant:purge new-checkout
Feature::activateForEveryone('new-checkout')
The workflow for incremental rollouts:
1. Deploy with Lottery::odds(1, 10) — 10% enabled
2. Monitor metrics for 48 hours
3. php artisan pennant:purge new-checkout
4. Update to Lottery::odds(1, 4) — 25% enabled
5. Monitor for 48 hours
6. Repeat until 100%
7. Feature::activateForEveryone('new-checkout')
8. Remove the flag definition and the conditional code
Rich Values: Beyond True/False
Feature flags don’t have to be boolean. Pennant supports any serialisable value — strings, arrays, integers:
// A/B testing with three variants
Feature::define('hero-layout', fn(User $user) =>
match ($user->id % 3) {
0 => 'layout-a',
1 => 'layout-b',
2 => 'layout-c',
}
);
// Check the value
$layout = Feature::value('hero-layout');
return view("landing.{$layout}");
// Feature with configuration — not just on/off
Feature::define('api-rate-limit', fn(User $user) =>
match (true) {
$user->plan === 'enterprise' => 10_000,
$user->plan === 'pro' => 1_000,
default => 100,
}
);
// Use the value
$limit = Feature::value('api-rate-limit') ?? 100;
RateLimiter::for('api', fn() => Limit::perMinute($limit)->by(auth()->id()));
Scoping Flags to Things Other Than Users
By default, Pennant scopes flags to the authenticated user. But scope can be any model — a team, a tenant, a subscription plan:
// Per-tenant feature flags (multi-tenancy)
Feature::define('advanced-reporting', fn(Tenant $tenant) =>
$tenant->plan === 'enterprise' ||
$tenant->beta_features_enabled
);
// Check for the current tenant
$tenant = Tenant::current();
if (Feature::for($tenant)->active('advanced-reporting')) {
// show advanced reports
}
// Per-team flags in a team-based app
Feature::define('team-analytics', fn(Team $team) =>
$team->subscription->plan === 'business'
);
if (Feature::for($request->user()->currentTeam)->active('team-analytics')) {
// show team analytics
}
Kill Switches: The Most Important Use Case
A kill switch is a feature flag that defaults to true and exists specifically to disable something quickly in production:
// Define as enabled by default — the "kill switch" pattern
Feature::define('payment-processing', fn() => true);
When something breaks in production:
// Disable for everyone immediately — no deployment
Feature::deactivateForEveryone('payment-processing');
// Show a maintenance message while you fix it
@unlessfeature('payment-processing')
<div class="alert">
Payment processing is temporarily unavailable.
We're working to restore it.
</div>
@endunlessfeature
// Re-enable once fixed
Feature::activateForEveryone('payment-processing');
The kill switch’s value isn’t in normal operation — it’s in the 3am incident where disabling a flag takes 10 seconds instead of running git revert, waiting for CI, and deploying.
Managing Flags: Artisan Commands
# List all stored feature flag values
php artisan pennant:list
# Activate a feature for everyone
php artisan pennant:activate new-checkout
# Deactivate for everyone
php artisan pennant:deactivate new-checkout
# Purge stored values — forces re-resolution on next check
php artisan pennant:purge new-checkout
# Purge all features
php artisan pennant:purge --all
Storage Drivers
Database Driver (Default)
The default — stores resolved values in the features table. Persistent, queryable, and shareable across all application servers.
// config/pennant.php
'default' => env('PENNANT_STORE', 'database'),
Array Driver (In-Memory)
For testing — no database writes, no persistence between requests:
// In tests
Feature::setDriver('array');
// Or per-test using Pennant's testing helpers
Feature::define('new-feature', fn() => true); // always active in tests
Feature::define('new-feature', fn() => false); // always inactive in tests
Custom Drivers
For teams using Redis, Memcached, or a dedicated feature flag store:
// config/pennant.php
'stores' => [
'redis' => [
'driver' => 'array', // start with a custom driver
'connection' => 'default',
],
],
Events: Monitoring Flag Usage
Pennant fires events throughout the flag lifecycle — useful for analytics, debugging, and cleanup:
// config/pennant.php — events you can listen to
use Laravel\Pennant\Events\FeatureChecked; // every check
use Laravel\Pennant\Events\FeatureResolved; // first resolution for a scope
use Laravel\Pennant\Events\UnknownFeatureResolved; // flag checked but not defined
The UnknownFeatureResolved event is particularly valuable:
// Listen for checks on flags that don't exist
// Catches stale flag references after you remove a flag
Event::listen(UnknownFeatureResolved::class, function ($event) {
Log::warning('Unknown feature flag checked', [
'feature' => $event->feature,
'scope' => $event->scope,
]);
// In development — throw an exception so you catch it immediately
if (app()->isLocal()) {
throw new \RuntimeException("Unknown feature flag: {$event->feature}");
}
});
This pattern catches a common maintenance problem: you remove a feature flag definition but leave Feature::active('old-feature') calls throughout the codebase. The event fires for every stale call — in development you get an exception, in production you get a log entry to track down.
Testing with Pennant
use Laravel\Pennant\Feature;
// In a test — activate a feature for the test scope
Feature::activate('new-checkout-flow');
// Run your test
$this->actingAs($user)
->get('/checkout')
->assertViewIs('checkout.new');
// Deactivate after
Feature::deactivate('new-checkout-flow');
// Or use Feature::fake() to isolate completely
Feature::fake([
'new-checkout-flow' => true,
'hero-layout' => 'layout-b',
]);
// Now Feature::active('new-checkout-flow') returns true
// Feature::value('hero-layout') returns 'layout-b'
// In the test, as expected
// Test all variants of an A/B test
it('renders the correct layout variant', function () {
foreach (['layout-a', 'layout-b', 'layout-c'] as $variant) {
Feature::fake(['hero-layout' => $variant]);
$this->get('/')->assertSee("layout-{$variant}");
}
});
The Real-World Workflow: From Flag to Feature Removal
Feature flags are not permanent. A flag that enables a feature fully deployed to everyone should be removed — the conditional code, the flag definition, and the stored database values. Stale flags accumulate and become technical debt.
Stage 1: Development
→ Define the flag (defaults to false)
→ Wrap new code in Feature::active('my-feature')
→ Enable for yourself via Feature::for($me)->activate('my-feature')
Stage 2: Internal testing
→ Enable for the dev team and QA users
→ Feature::define('my-feature', fn($u) => $u->hasRole('dev') || $u->is_qa)
Stage 3: Beta rollout
→ Add beta_testers to the definition
→ Feature::define('my-feature', fn($u) => $u->is_beta || $u->hasRole('dev'))
Stage 4: Gradual public rollout
→ Feature::define('my-feature', Lottery::odds(1, 10)) // 10%
→ Purge and re-resolve at 25%, 50%, 100%
Stage 5: Full rollout
→ Feature::activateForEveryone('my-feature')
→ Schedule flag removal within the next sprint
Stage 6: Cleanup
→ Remove Feature::active('my-feature') checks from code
→ Remove the Feature::define() from AppServiceProvider
→ php artisan pennant:purge my-feature
→ Delete any migration or config that referenced the flag
The Complete Pennant Checklist
Setup:
✓ Pennant installed and migrated
✓ Features defined in AppServiceProvider or dedicated FeatureServiceProvider
✓ Class-based features in app/Features/ for complex logic
Blade:
✓ @feature / @endfeature directives used in templates
✓ Feature::value() used for A/B variant tracking
Rollout:
✓ Lottery::odds() used for percentage rollouts
✓ pennant:purge run before increasing rollout percentage
✓ Beta testers and admins always in the enabled group
Kill switches:
✓ High-risk features have a corresponding kill switch defined
✓ Kill switch check at the entry point (middleware or controller)
✓ Team knows the Artisan command to disable: pennant:deactivate
Events:
✓ UnknownFeatureResolved listener configured to catch stale flags
✓ FeatureChecked listener used for analytics if needed
Testing:
✓ Feature::fake() used in tests — no real database writes
✓ Both enabled and disabled states tested
✓ All A/B variants tested
Cleanup:
✓ Flags removed from code once fully deployed to everyone
✓ pennant:purge run after flag removal
✓ No stale Feature::active() calls remaining (caught by UnknownFeatureResolved)
Pennant vs LaunchDarkly: The Honest Comparison
| Pennant | LaunchDarkly | |
|---|---|---|
| Cost | $0 | $300–$700/month |
| User model | Your Eloquent models | Their SDK user model |
| Storage | Your database | Their cloud |
| SDK dependency | None | Required in every check |
| Dashboard UI | None built-in | Excellent |
| Non-technical user access | Via Artisan or custom UI | Yes, built-in |
| Percentage rollouts | ✅ Lottery | ✅ |
| A/B testing | ✅ Rich values | ✅ |
| Gradual rollouts | ✅ | ✅ |
| Multi-scope (tenant, team) | ✅ Any model | Limited |
| Offline / no internet | ✅ Self-hosted | ❌ |
| Data privacy | ✅ Stays in your DB | ❌ Sent to their cloud |
| Setup time | 10 minutes | 30+ minutes |
When LaunchDarkly wins: When non-technical team members need to manage flags without developer involvement — their dashboard is excellent for product managers and marketers. If the team controlling flags is primarily non-technical, a SaaS with a proper dashboard is worth the cost.
When Pennant wins: Everything else. If the engineers own the flags, Pennant is strictly better — zero cost, zero external dependency, native integration with your models and database, and features that work offline.
Final Thoughts
Feature flags are not a luxury — they’re the mechanism that lets teams ship continuously without all-or-nothing releases. Every feature that goes to production without a flag is a feature that requires a deployment to roll back.
Pennant makes the right tool available to every Laravel application regardless of budget. The API is clean, the integration is native, and the entire setup takes ten minutes. For teams currently paying for a feature flag SaaS, the migration path is straightforward — define your flags in Pennant, migrate the storage, and cancel the subscription.
The kill switch alone is worth it. The gradual rollout capability alone is worth it. Together with A/B testing, beta programs, and per-tenant flags, Pennant covers every use case that drove teams to spend $500/month on a SaaS tool.
Ship behind a flag. Enable gradually. Kill instantly when something breaks. Clean up when done. That’s the workflow. Pennant handles the infrastructure.
