基础用法请参考指南页面 。本文介绍官方文档没有涉及的实战模式。
多租户 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' ),
]);
FeatureResolved 与 FeatureRetrieved 的区别: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 指南 安装和基础用法请参考指南页面。