跳转到主要内容
基础用法请参考指南页面。本文介绍官方文档没有涉及的实战模式。

多租户 SaaS 中的团队范围

如果想按团队(租户)而不是按个人用户来管理标志,可以把默认范围改成团队。
// AppServiceProvider
Feature::resolveScopeUsing(fn () => Auth::user()?->currentTeam);
这样 Feature::active('billing-v2') 就会自动以当前团队为对象。无论团队成员是谁,只要属于同一个团队,得到的结果就一致,UI 也能保持一致。 以下是按团队注册时间做灰度发布的例子。
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);
});
只想对特定团队开启的场景(例如面向企业客户的抢先体验):
// 部署后手动为指定团队开启
Feature::for($earlyAccessTeam)->activate('new-billing');

紧急 kill switch(利用 before 方法)

生产环境发现 bug 时,可以不回滚代码就立即关闭某个功能。给基于类的 feature 添加 before 方法,检查会先于存储中的值执行。
<?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。
before 返回 null 时会执行 resolve(),返回 false 时立即视为未激活。除紧急情况外,请返回 null

定时发布

想在某个特定日期自动向所有用户开放的场景。用 before 方法即可实现。
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();
}
# .env
FEATURES_NEW_API_ROLLOUT_DATE=2025-04-01
这样过了 2025-04-01 就会自动向所有用户开放。无需部署、无需 DB、无需 Artisan 命令,自动化完成。

暗发布(Shadow Mode)

一种在不给用户看到的前提下用生产数据运行新逻辑,并把结果与旧逻辑比较的模式。没有问题时,只需要打开开关就完成发布。
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 值首次被解析时触发。可以用它来记录用户被分配到的变体。
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,
    ]);
});
在转化发生时另行记录,就能按变体统计转化率。
// 购买完成时
Event::dispatch(new PurchaseCompleted($user, $product));
// PurchaseCompleted 监听器
analytics()->track('purchase_completed', [
    'user_id'              => $user->id,
    'ab_purchase_button'   => Feature::for($user)->value('purchase-button'),
]);
FeatureResolvedFeatureRetrieved 的区别:FeatureResolved 只在首次评估时触发,FeatureRetrieved 每次检查都会触发。记录变体分配用 FeatureResolved,页面浏览追踪用 FeatureRetrieved 更合适。

队列作业中的范围

在队列作业中不存在已认证用户,所以 feature 检查可能出现意外行为。请给作业显式传入范围。
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);
        }
    }
}
也可以在派发作业时评估好标志,然后传给作业。
// 在控制器里评估后传给作业
$useNewLayout = Feature::for($user)->active('new-digest-layout');

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

用 Artisan 命令做管理 UI

写一个管理标志的简单 Artisan 命令,不用部署就能操作生产标志。
<?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} 个范围处于启用状态");
    }
}
php artisan feature activate new-checkout
php artisan feature deactivate new-checkout
php artisan feature status new-checkout

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

在重命名基于类的 feature 时,如果 DB 中保存的标志名跟着变,所有用户的标志都会被重置。用 Name 属性固定保存名。
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 属性类的安全重构

Laravel Pennant 指南

安装和基础用法请参考指南页面。
最后修改于 2026年7月13日