Skip to main content

Documentation Index

Fetch the complete documentation index at: https://kawax.biz/llms.txt

Use this file to discover all available pages before exploring further.

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