> ## Documentation Index
> Fetch the complete documentation index at: https://kawax.biz/llms.txt
> Use this file to discover all available pages before exploring further.

# Laravel Pennant real-world use cases

> Practical patterns for Laravel Pennant beyond the basics. Team-scoped flags, emergency kill switches, dark launches, admin commands, and know-how not covered in the official docs.

For the basics, see the [guide page](/en/pennant). 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.

```php theme={null}
// 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.

```php theme={null}
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):

```php theme={null}
// 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 theme={null}
<?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.

<Tip>
  When `before` returns `null`, `resolve()` runs. Returning `false` immediately treats the flag as inactive. Return `null` in non-emergency situations.
</Tip>

***

## 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.

```php theme={null}
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();
}
```

```ini theme={null}
# .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.

```php theme={null}
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.

```php theme={null}
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.

```php theme={null}
// On completed purchase
Event::dispatch(new PurchaseCompleted($user, $product));
```

```php theme={null}
// PurchaseCompleted listener
analytics()->track('purchase_completed', [
    'user_id'              => $user->id,
    'ab_purchase_button'   => Feature::for($user)->value('purchase-button'),
]);
```

<Info>
  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.
</Info>

***

## 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.

```php theme={null}
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.

```php theme={null}
// 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 theme={null}
<?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");
    }
}
```

```shell theme={null}
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.

```php theme={null}
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.

| Pattern                    | Use case                                      |
| -------------------------- | --------------------------------------------- |
| Team scope                 | Multi-tenant SaaS                             |
| `before` kill switch       | Emergency response for production bugs        |
| Scheduled rollouts         | Automated releases at a specific time         |
| Dark launch                | Safe production validation of a new algorithm |
| `FeatureResolved` event    | Accurate collection of A/B test results       |
| Explicit scope in jobs     | Correct flag evaluation in queue environments |
| Management Artisan command | Manipulate production flags without deploying |
| `Name` attribute           | Safe class refactoring                        |

<Card title="Laravel Pennant guide" icon="flag" href="/en/pennant">
  See the guide page for installation and basic usage.
</Card>


## Related topics

- [PHP FFI](/en/advanced/ffi.md)
- [The Lottery class](/en/advanced/lottery.md)
- [Client Mode: Song (Singing Synthesis) - VOICEVOX for Laravel](/en/packages/laravel-voicevox/client-song.md)
- [Laravel Agent Detector — AI agent detection package](/en/blog/agent-detector-introduction.md)
- [Laravel Cloud — a full look at the Laravel-native PaaS](/en/blog/laravel-cloud.md)
