Skip to main content
For installation and basic usage, see the guide page. This post covers practical patterns that go beyond the official documentation.

Team-scoped flags for multi-tenant SaaS

When you need to roll features out per-team rather than per-user, change the default scope to the current team:
// AppServiceProvider
Feature::resolveScopeUsing(fn () => Auth::user()?->currentTeam);
Now Feature::active('billing-v2') automatically checks against the current team — no for() call needed anywhere in the codebase. All members of a team get the same experience, and consistency is guaranteed automatically. Roll out gradually based on how long a team has been a customer:
use App\Models\Team;
use Illuminate\Support\Carbon;
use Illuminate\Support\Lottery;
use Laravel\Pennant\Feature;

Feature::define('new-billing', function (Team $team) {
    // Teams registered after Jan 2024 get immediate access
    if ($team->created_at->isAfter(new Carbon('2024-01-01'))) {
        return true;
    }

    // 2022–2023 registrations: slow 10% rollout
    if ($team->created_at->isAfter(new Carbon('2022-01-01'))) {
        return Lottery::odds(1 / 10);
    }

    // Oldest customers: cautious 1% rollout
    return Lottery::odds(1 / 100);
});
For early-access agreements (enterprise accounts, design partners), activate manually after the definition runs:
Feature::for($earlyAccessTeam)->activate('new-billing');

Emergency kill switch with the before method

When a production bug surfaces, you want to disable the feature without rolling back code or modifying the database. Class-based features support a before method that runs in-memory before the stored value is retrieved.
<?php

namespace App\Features;

use App\Models\User;
use Illuminate\Support\Facades\Config;

class NewCheckout
{
    /**
     * Emergency kill switch — set FEATURES_NEW_CHECKOUT_DISABLED=true to disable globally.
     */
    public function before(User $user): mixed
    {
        if (Config::boolean('features.new_checkout_disabled', false)) {
            return false; // Disable immediately for everyone
        }

        // Keep admins enabled for debugging
        if ($user->isAdmin()) {
            return true;
        }

        return null; // null passes through to resolve()
    }

    public function resolve(User $user): mixed
    {
        return $user->isPremium() || Lottery::odds(1 / 5);
    }
}
Set FEATURES_NEW_CHECKOUT_DISABLED=true in your environment and the feature goes dark instantly — no deploy, no database query, no downtime window.
Returning null from before passes control to resolve(). Returning any non-null value — including false — short-circuits the stored value for the duration of the request.

Scheduled rollouts

Automate a date-triggered global release using the before method, no cron job required:
public function before(User $user): mixed
{
    $rolloutDate = Config::get('features.new_api.rollout_date');

    if ($rolloutDate && Carbon::parse($rolloutDate)->isPast()) {
        return true; // Auto-release when the date passes
    }

    return null; // Before the date: fall through to resolve()
}

public function resolve(User $user): mixed
{
    return $user->isInternalTeamMember();
}
# .env
FEATURES_NEW_API_ROLLOUT_DATE=2025-06-01
Past that date the feature becomes active for everyone. No deploy needed, no manual activation — the change is fully automated from the moment you set the env variable.

Dark launch (shadow mode)

Run a new code path against real production data without exposing it to users. Once the logs show consistent results, flip the flag to release.
class RecommendationController
{
    public function index(Request $request)
    {
        $legacyResult = $this->getLegacyRecommendations($request->user());

        // Shadow: run new logic, compare, but always respond with the legacy result
        if (Feature::for($request->user())->active('recommendation-v2-shadow')) {
            try {
                $newResult = $this->getNewRecommendations($request->user());

                if ($legacyResult !== $newResult) {
                    Log::channel('shadow_mode')->info('recommendation diff', [
                        'user_id' => $request->user()->id,
                        'legacy'  => $legacyResult,
                        'new'     => $newResult,
                    ]);
                }
            } catch (\Throwable $e) {
                Log::error('shadow mode error', ['error' => $e->getMessage()]);
            }
        }

        // Always respond with the known-good result
        return response()->json($legacyResult);
    }
}
When the diff count in the shadow log reaches zero, switch the flag from recommendation-v2-shadow to recommendation-v2.

Tracking A/B test results properly

The official docs mention FeatureRetrieved, but the correct event for tracking variant assignment is FeatureResolved — it fires only when the value is resolved for the first time for a scope.
use Laravel\Pennant\Events\FeatureResolved;
use Illuminate\Support\Facades\Event;

// Register in AppServiceProvider or EventServiceProvider
Event::listen(function (FeatureResolved $event) {
    if ($event->feature !== 'purchase-button') {
        return;
    }

    // Record the variant assignment at the moment it is first determined
    analytics()->identify($event->scope?->id, [
        'ab_purchase_button' => $event->value,
    ]);
});
Record conversions separately by reading the persisted flag value:
// Inside your purchase completion handler
analytics()->track('purchase_completed', [
    'user_id'            => $user->id,
    'ab_purchase_button' => Feature::for($user)->value('purchase-button'),
]);
FeatureResolved vs FeatureRetrieved: FeatureResolved fires only on first evaluation (variant assignment). FeatureRetrieved fires on every check (page views, API calls). Use FeatureResolved for attribution; use FeatureRetrieved for impression counts.

Feature flags in queued jobs

Queued jobs run outside the HTTP context, so there is no authenticated user. Accessing Feature::active() without an explicit scope defaults to null, which returns false for most flag definitions — silently breaking the expected behavior.
class SendWeeklyDigest implements ShouldQueue
{
    public function __construct(
        private readonly User $user
    ) {}

    public function handle(): void
    {
        // BAD: no authenticated user in queue context → always false
        // if (Feature::active('new-digest-layout')) { ... }

        // GOOD: pass the scope explicitly
        if (Feature::for($this->user)->active('new-digest-layout')) {
            $this->sendNewLayout($this->user);
        } else {
            $this->sendLegacyLayout($this->user);
        }
    }
}
Alternatively, evaluate the flag before dispatching and pass the resolved boolean to the job:
// Evaluate in the controller where the session exists
$useNewLayout = Feature::for($user)->active('new-digest-layout');

SendWeeklyDigest::dispatch($user, $useNewLayout);

A simple feature management Artisan command

Without a full admin panel, a small Artisan command lets you toggle flags in production without code changes or deployments.
<?php

namespace App\Console\Commands;

use Illuminate\Console\Command;
use Laravel\Pennant\Feature;

class ManageFeatureFlag extends Command
{
    protected $signature = 'feature {action : activate|deactivate|status} {feature : Feature name}';
    protected $description = 'Manage feature flags';

    public function handle(): void
    {
        $feature = $this->argument('feature');

        match ($this->argument('action')) {
            'activate'   => $this->activate($feature),
            'deactivate' => $this->deactivate($feature),
            'status'     => $this->status($feature),
            default      => $this->error('Unknown action'),
        };
    }

    private function activate(string $feature): void
    {
        Feature::activateForEveryone($feature);
        $this->info("✓ {$feature} activated for all users");
    }

    private function deactivate(string $feature): void
    {
        Feature::deactivateForEveryone($feature);
        $this->info("✓ {$feature} deactivated for all users");
    }

    private function status(string $feature): void
    {
        $count = \DB::table('features')
            ->where('name', $feature)
            ->where('value', json_encode(true))
            ->count();

        $this->line("{$feature}: active for {$count} scope(s)");
    }
}
php artisan feature activate new-checkout
php artisan feature deactivate new-checkout
php artisan feature status new-checkout

Safe refactoring with the Name attribute

When you rename a class-based feature, Pennant’s stored key changes (it uses the fully qualified class name by default), causing all persisted flag values to be lost. Fix the stored name before renaming:
use Laravel\Pennant\Attributes\Name;

// Renaming CheckoutV2 → NewCheckoutExperience
// The stored key stays 'checkout-v2' — no data loss
#[Name('checkout-v2')]
class NewCheckoutExperience
{
    public function resolve(User $user): mixed
    {
        return $user->isPremium();
    }
}
Add the Name attribute before refactoring the class name, and the database records remain intact.

Summary

PatternWhen to use it
Team scopeMulti-tenant SaaS where flags apply to whole organizations
before kill switchEmergency disable without code deploy
Scheduled rolloutDate-triggered automatic release
Dark launchValidate new logic against production data before exposing it
FeatureResolved eventAccurate A/B variant attribution
Explicit scope in jobsCorrect flag evaluation outside HTTP context
Management Artisan commandToggle flags in production without a full admin panel
Name attributeRefactor feature class names safely

Laravel Pennant Guide

Installation and core API reference are covered in the guide page.
Last modified on May 19, 2026