> ## 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 实战用例

> 介绍超出基础用法的 Laravel Pennant 实战模式。团队范围、紧急 kill switch、暗发布、管理命令等,分享官方文档不会告诉你的实践经验。

基础用法请参考[指南页面](/zh/pennant)。本文介绍官方文档没有涉及的实战模式。

***

## 多租户 SaaS 中的团队范围

如果想按团队(租户)而不是按个人用户来管理标志,可以把默认范围改成团队。

```php theme={null}
// AppServiceProvider
Feature::resolveScopeUsing(fn () => Auth::user()?->currentTeam);
```

这样 `Feature::active('billing-v2')` 就会自动以当前团队为对象。无论团队成员是谁,只要属于同一个团队,得到的结果就一致,UI 也能保持一致。

以下是按团队注册时间做灰度发布的例子。

```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) {
    // 2024 年以后注册的团队立即启用
    if ($team->created_at->isAfter(new Carbon('2024-01-01'))) {
        return true;
    }

    // 2022~2023 年注册的团队按 10% 灰度
    if ($team->created_at->isAfter(new Carbon('2022-01-01'))) {
        return Lottery::odds(1 / 10);
    }

    // 更早的团队保守从 1% 开始
    return Lottery::odds(1 / 100);
});
```

只想对特定团队开启的场景(例如面向企业客户的抢先体验):

```php theme={null}
// 部署后手动为指定团队开启
Feature::for($earlyAccessTeam)->activate('new-billing');
```

***

## 紧急 kill switch(利用 `before` 方法)

生产环境发现 bug 时,可以不回滚代码就立即关闭某个功能。给基于类的 feature 添加 `before` 方法,检查会先于存储中的值执行。

```php theme={null}
<?php

namespace App\Features;

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

class NewCheckout
{
    /**
     * 生产 bug 出现时可通过 config 立即关闭的紧急开关
     */
    public function before(User $user): mixed
    {
        // 用 config/features.php 或 env 控制
        if (Config::boolean('features.new_checkout_disabled', false)) {
            return false; // 对所有人立即关闭
        }

        // 管理员始终启用(便于调试)
        if ($user->isAdmin()) {
            return true;
        }

        return null; // 返回 null 会走正常的 resolve()
    }

    public function resolve(User $user): mixed
    {
        return $user->isPremium() || Lottery::odds(1 / 5);
    }
}
```

只需要设置环境变量 `FEATURES_NEW_CHECKOUT_DISABLED=true`,就能在不动 DB 的情况下关闭功能。这是无需部署的 kill switch。

<Tip>
  `before` 返回 `null` 时会执行 `resolve()`,返回 `false` 时立即视为未激活。除紧急情况外,请返回 `null`。
</Tip>

***

## 定时发布

想在某个特定日期自动向所有用户开放的场景。用 `before` 方法即可实现。

```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; // 到达发布日期后对所有人开放
    }

    return null; // 还没到就走正常 resolve()
}

public function resolve(User $user): mixed
{
    // 发布前只对内部成员开放
    return $user->isInternalTeamMember();
}
```

```ini theme={null}
# .env
FEATURES_NEW_API_ROLLOUT_DATE=2025-04-01
```

这样过了 `2025-04-01` 就会自动向所有用户开放。无需部署、无需 DB、无需 Artisan 命令,自动化完成。

***

## 暗发布(Shadow Mode)

一种在不给用户看到的前提下用生产数据运行新逻辑,并把结果与旧逻辑比较的模式。没有问题时,只需要打开开关就完成发布。

```php theme={null}
class RecommendationController
{
    public function index(Request $request)
    {
        $legacyResult = $this->getLegacyRecommendations($request->user());

        // shadow 模式下运行新算法并比较,但对外展示的仍是旧逻辑
        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()]);
            }
        }

        // 响应始终使用旧逻辑的结果
        return response()->json($legacyResult);
    }
}
```

确认日志中没有差异后,把标志切换到 `recommendation-v2` 就好。

***

## 用事件来收集 A/B 测试结果

官方文档提到了 `FeatureRetrieved` 事件,但没有给出实际的 A/B 测试收敛模式。

`FeatureResolved` 事件仅在 feature 值**首次**被解析时触发。可以用它来记录用户被分配到的变体。

```php theme={null}
use Laravel\Pennant\Events\FeatureResolved;
use Illuminate\Support\Facades\Event;

// 在 AppServiceProvider 或 EventServiceProvider 中注册
Event::listen(function (FeatureResolved $event) {
    if ($event->feature !== 'purchase-button') {
        return;
    }

    // 在用户被分配变体的时点记录
    analytics()->identify($event->scope?->id, [
        'ab_purchase_button' => $event->value,
    ]);
});
```

在转化发生时另行记录,就能按变体统计转化率。

```php theme={null}
// 购买完成时
Event::dispatch(new PurchaseCompleted($user, $product));
```

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

<Info>
  `FeatureResolved` 与 `FeatureRetrieved` 的区别:`FeatureResolved` 只在首次评估时触发,`FeatureRetrieved` 每次检查都会触发。记录变体分配用 `FeatureResolved`,页面浏览追踪用 `FeatureRetrieved` 更合适。
</Info>

***

## 队列作业中的范围

在队列作业中不存在已认证用户,所以 feature 检查可能出现意外行为。请给作业显式传入范围。

```php theme={null}
class SendWeeklyDigest implements ShouldQueue
{
    public function __construct(
        private readonly User $user
    ) {}

    public function handle(): void
    {
        // 不好的做法:没有认证用户,会始终得到 false
        // if (Feature::active('new-digest-layout')) { ... }

        // 正确做法:显式传入用户
        if (Feature::for($this->user)->active('new-digest-layout')) {
            $this->sendNewLayout($this->user);
        } else {
            $this->sendLegacyLayout($this->user);
        }
    }
}
```

也可以在派发作业时评估好标志,然后传给作业。

```php theme={null}
// 在控制器里评估后传给作业
$useNewLayout = Feature::for($user)->active('new-digest-layout');

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

***

## 用 Artisan 命令做管理 UI

写一个管理标志的简单 Artisan 命令,不用部署就能操作生产标志。

```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 = '管理功能开关';

    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('未知的操作'),
        };
    }

    private function activate(string $feature): void
    {
        Feature::activateForEveryone($feature);
        $this->info("✓ 已为所有用户启用 {$feature}");
    }

    private function deactivate(string $feature): void
    {
        Feature::deactivateForEveryone($feature);
        $this->info("✓ 已为所有用户关闭 {$feature}");
    }

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

        $this->line("{$feature}: 有 {$count} 个范围处于启用状态");
    }
}
```

```shell theme={null}
php artisan feature activate new-checkout
php artisan feature deactivate new-checkout
php artisan feature status new-checkout
```

***

## Feature 名称的安全重构(`Name` 属性)

在重命名基于类的 feature 时,如果 DB 中保存的标志名跟着变,所有用户的标志都会被重置。用 `Name` 属性固定保存名。

```php theme={null}
use Laravel\Pennant\Attributes\Name;

// 旧类名:CheckoutV2 → 新类名:NewCheckoutExperience
// DB 中始终以 'checkout-v2' 保存
#[Name('checkout-v2')]
class NewCheckoutExperience
{
    public function resolve(User $user): mixed
    {
        return $user->isPremium();
    }
}
```

这样即便你自由重构类名,DB 里的数据也不会变。

***

## 总结

在掌握官方文档的基础用法后,以下模式能真正发挥 Pennant 的价值。

| 模式                   | 适用场景          |
| -------------------- | ------------- |
| 团队范围                 | 多租户 SaaS      |
| `before` kill switch | 生产 bug 的紧急应对  |
| 定时发布                 | 按日期发布的自动化     |
| 暗发布                  | 安全地在生产上验证新算法  |
| `FeatureResolved` 事件 | 精确收集 A/B 测试结果 |
| 作业显式传范围              | 队列环境中准确评估标志   |
| 管理 Artisan 命令        | 无需部署即可操作生产标志  |
| `Name` 属性            | 类的安全重构        |

<Card title="Laravel Pennant 指南" icon="flag" href="/zh/pennant">
  安装和基础用法请参考指南页面。
</Card>


## Related topics

- [InteractsWithData trait](/zh/advanced/interacts-with-data.md)
- [Eloquent 作用域](/zh/advanced/eloquent-scopes.md)
- [Laravel Telescope 实战技巧](/zh/blog/telescope-introduction.md)
- [Laravel Pennant](/zh/pennant.md)
- [文件存储](/zh/filesystem.md)
