Skip to main content
For the basics, see the guide page. This page introduces practical patterns not covered in the official docs.

Team-scoped flags in a multi-tenant SaaS

If you want to manage flags per team (tenant) rather than per individual user, change the default scope to the team.
// AppServiceProvider
Feature::resolveScopeUsing(fn () => Auth::user()?->currentTeam);
That’s all it takes for Feature::active('billing-v2') to automatically target the current team. Because everyone on the same team gets the same result, UI consistency is preserved regardless of the member. Here’s an example of a gradual rollout based on a team’s signup date.
use App\Models\Team;
use Illuminate\Support\Carbon;
use Illuminate\Support\Lottery;
use Laravel\Pennant\Feature;

Feature::define('new-billing', function (Team $team) {
    // Teams that registered in 2024 or later are enabled immediately
    if ($team->created_at->isAfter(new Carbon('2024-01-01'))) {
        return true;
    }

    // Teams from 2022-2023 roll out at 10%
    if ($team->created_at->isAfter(new Carbon('2022-01-01'))) {
        return Lottery::odds(1 / 10);
    }

    // Older teams start conservatively at 1%
    return Lottery::odds(1 / 100);
});
To enable only for specific teams (e.g. early access for enterprise customers):
// Manually enable for a specific team after deploy
Feature::for($earlyAccessTeam)->activate('new-billing');

Emergency kill switch (using the before method)

When a bug is discovered in production, you can instantly disable a feature without rolling back code. Adding a before method to a class-based feature makes the check run before the storage value is consulted.
<?php

namespace App\Features;

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

class NewCheckout
{
    /**
     * Emergency switch that lets you disable via config on production bugs
     */
    public function before(User $user): mixed
    {
        // Controlled via config/features.php value, or env
        if (Config::boolean('features.new_checkout_disabled', false)) {
            return false; // Immediately disable for everyone
        }

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

        return null; // Return null to fall through to normal resolve()
    }

    public function resolve(User $user): mixed
    {
        return $user->isPremium() || Lottery::odds(1 / 5);
    }
}
Setting the environment variable FEATURES_NEW_CHECKOUT_DISABLED=true alone stops the feature without touching the database. A kill switch that requires no deploy.
When before returns null, resolve() runs. Returning false immediately treats the flag as inactive. Return null in non-emergency situations.

Scheduled rollouts

The case where you want to automatically enable for all users at a specific date/time. You can implement this in the before method.
public function before(User $user): mixed
{
    $rolloutDate = Config::get('features.new_api.rollout_date');

    if ($rolloutDate && Carbon::parse($rolloutDate)->isPast()) {
        return true; // Once past the rollout date, everyone is enabled
    }

    return null; // Otherwise fall through to normal resolve()
}

public function resolve(User $user): mixed
{
    // Before rollout, internal team members only
    return $user->isInternalTeamMember();
}
# .env
FEATURES_NEW_API_ROLLOUT_DATE=2025-04-01
Now, once 2025-04-01 passes, the feature automatically ships to all users. No deploy, no DB update, no Artisan command needed.

Dark launch (shadow mode)

A pattern where you run new logic on production data without showing it to users, and compare results to the old logic. If nothing goes wrong, you release simply by flipping the flag.
class RecommendationController
{
    public function index(Request $request)
    {
        $legacyResult = $this->getLegacyRecommendations($request->user());

        // In shadow mode, run the new algorithm and compare, but show legacy
        if (Feature::for($request->user())->active('recommendation-v2-shadow')) {
            try {
                $newResult = $this->getNewRecommendations($request->user());

                // Log differences (not shown to users)
                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()]);
            }
        }

        // Response is always the legacy result
        return response()->json($legacyResult);
    }
}
Once you’ve confirmed the logs show no differences, switch the flag to recommendation-v2 to release.

Collecting A/B test results with events

The official docs mention the FeatureRetrieved event, but do not show a real A/B test aggregation pattern. The FeatureResolved event fires only the first time a feature’s value is resolved. Use it to record a user’s variant assignment.
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 when it happens
    analytics()->identify($event->scope?->id, [
        'ab_purchase_button' => $event->value,
    ]);
});
Record conversions separately when they happen, and you can compute the conversion rate per variant.
// On completed purchase
Event::dispatch(new PurchaseCompleted($user, $product));
// PurchaseCompleted listener
analytics()->track('purchase_completed', [
    'user_id'              => $user->id,
    'ab_purchase_button'   => Feature::for($user)->value('purchase-button'),
]);
Difference between FeatureResolved and FeatureRetrieved: FeatureResolved fires only on the initial evaluation, while FeatureRetrieved fires on every check. Use FeatureResolved for assignment tracking and FeatureRetrieved for pageview tracking.

Scoping in queued jobs

In queued jobs there is no authenticated user, so feature checks can behave unexpectedly. Pass an explicit scope to the job.
class SendWeeklyDigest implements ShouldQueue
{
    public function __construct(
        private readonly User $user
    ) {}

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

        // GOOD: explicitly pass the user
        if (Feature::for($this->user)->active('new-digest-layout')) {
            $this->sendNewLayout($this->user);
        } else {
            $this->sendLegacyLayout($this->user);
        }
    }
}
You can also evaluate the flag at dispatch time and pass the result to the job.
// Evaluate in the controller and pass to the job
$useNewLayout = Feature::for($user)->active('new-digest-layout');

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

Admin UI via Artisan commands

Building a simple Artisan command to manage flags lets you operate production flags without deploying.
<?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("✓ Activated {$feature} for all users");
    }

    private function deactivate(string $feature): void
    {
        Feature::deactivateForEveryone($feature);
        $this->info("✓ Deactivated {$feature} 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}: enabled for {$count} scopes");
    }
}
php artisan feature activate new-checkout
php artisan feature deactivate new-checkout
php artisan feature status new-checkout

Safe class refactoring for feature names (the Name attribute)

When renaming a class-based feature, changing the flag name stored in the DB would reset the flag for all users. Use the Name attribute to fix the stored name.
use Laravel\Pennant\Attributes\Name;

// Old class name: CheckoutV2 → New class name: NewCheckoutExperience
// Always stored in the DB as 'checkout-v2'
#[Name('checkout-v2')]
class NewCheckoutExperience
{
    public function resolve(User $user): mixed
    {
        return $user->isPremium();
    }
}
Now you can rename the class freely while keeping the DB data as-is.

Summary

Once you’re comfortable with the basics from the official docs, the following patterns bring out Pennant’s real value.
PatternUse case
Team scopeMulti-tenant SaaS
before kill switchEmergency response for production bugs
Scheduled rolloutsAutomated releases at a specific time
Dark launchSafe production validation of a new algorithm
FeatureResolved eventAccurate collection of A/B test results
Explicit scope in jobsCorrect flag evaluation in queue environments
Management Artisan commandManipulate production flags without deploying
Name attributeSafe class refactoring

Laravel Pennant guide

See the guide page for installation and basic usage.
Last modified on July 13, 2026