Laravel Pennant: Feature Flags Done Right — Without the $500/Month SaaS Bill

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

PennantLaunchDarkly
Cost$0$300–$700/month
User modelYour Eloquent modelsTheir SDK user model
StorageYour databaseTheir cloud
SDK dependencyNoneRequired in every check
Dashboard UINone built-inExcellent
Non-technical user accessVia Artisan or custom UIYes, built-in
Percentage rollouts✅ Lottery
A/B testing✅ Rich values
Gradual rollouts
Multi-scope (tenant, team)✅ Any modelLimited
Offline / no internet✅ Self-hosted
Data privacy✅ Stays in your DB❌ Sent to their cloud
Setup time10 minutes30+ 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.

Leave a Reply

Your email address will not be published. Required fields are marked *