跳转到主要内容

什么是 Laravel Pennant

Laravel Pennant 是一个轻量、简洁的功能开关(Feature Flag)扩展包。功能开关可以让你分阶段发布新功能、进行 A/B 测试,或者补充主干开发流程。

什么是功能开关

借助功能开关,可以将代码部署与功能发布分离。代码可以先部署到生产环境,通过配置来打开或关闭功能。

安装

1

安装扩展包

通过 Composer 安装 Pennant。
composer require laravel/pennant
2

发布配置文件与迁移

通过 vendor:publish Artisan 命令发布文件。
php artisan vendor:publish --provider="Laravel\Pennant\PennantServiceProvider"
这会在 config/pennant.phpdatabase/migrations 中生成对应文件。
3

执行迁移

创建 Pennant 用来保存功能开关值的 features 表。
php artisan migrate

配置

config/pennant.php 中可以配置要使用的存储驱动,Pennant 支持两种。
驱动说明
database将值持久化到关系型数据库(默认)
array保存在内存中(适合测试或临时使用)
// config/pennant.php
'default' => env('PENNANT_STORE', 'database'),

定义功能

基于闭包的定义

功能通过 Feature Facade 的 define 方法定义,通常写在服务提供者的 boot 方法中。闭包会接收一个“作用域(scope)”参数(一般是已认证用户)。
<?php

namespace App\Providers;

use App\Models\User;
use Illuminate\Support\Lottery;
use Illuminate\Support\ServiceProvider;
use Laravel\Pennant\Feature;

class AppServiceProvider extends ServiceProvider
{
    public function boot(): void
    {
        Feature::define('new-api', fn (User $user) => match (true) {
            $user->isInternalTeamMember() => true,
            $user->isHighTrafficCustomer() => false,
            default => Lottery::odds(1 / 100),
        });
    }
}
上面这个功能的逻辑是:
  • 团队内部成员始终开启
  • 高流量客户始终关闭
  • 其余用户按 1% 的概率开启
当功能首次被检查时,闭包返回的结果会写入存储驱动,后续判断都直接使用已保存的值。
如果定义体只是返回 Lottery,可以省略闭包。
Feature::define('site-redesign', Lottery::odds(1, 1000));

基于类的定义

Pennant 也支持基于类的功能定义。基于类时无需在服务提供者中注册。
php artisan pennant:feature NewApi
生成的类位于 app/Features 目录,只需实现 resolve 方法。
<?php

namespace App\Features;

use App\Models\User;
use Illuminate\Support\Lottery;

class NewApi
{
    /**
     * 解析功能的初始值。
     */
    public function resolve(User $user): mixed
    {
        return match (true) {
            $user->isInternalTeamMember() => true,
            $user->isHighTrafficCustomer() => false,
            default => Lottery::odds(1 / 100),
        };
    }
}

自定义保存名

默认使用完全限定类名保存。可以通过 Name Attribute 自定义名字。
use Laravel\Pennant\Attributes\Name;

#[Name('new-api')]
class NewApi
{
    // ...
}

拦截判断(before 方法)

基于类的功能可以定义 before 方法。它会在读取存储值之前在内存中执行,若返回非 null 值就直接使用该值。
class NewApi
{
    public function before(User $user): mixed
    {
        if (Config::get('features.new-api.disabled')) {
            return $user->isInternalTeamMember();
        }
    }

    public function resolve(User $user): mixed
    {
        // ...
    }
}
before 方法适合在出现 bug 时紧急关闭功能,或在指定时间点做灰度发布。

判断功能状态

Feature::active() / Feature::inactive()

active 方法可以判断功能是否启用。默认使用当前已认证用户作为作用域。
use Laravel\Pennant\Feature;

if (Feature::active('new-api')) {
    // 使用新版 API 的处理
}
基于类的功能则传入类名。
use App\Features\NewApi;

if (Feature::active(NewApi::class)) {
    // ...
}
其他常用方法:
// 是否所有功能都启用
Feature::allAreActive(['new-api', 'site-redesign']);

// 是否有任意功能启用
Feature::someAreActive(['new-api', 'site-redesign']);

// 是否功能未启用
Feature::inactive('new-api');

// 是否所有功能都未启用
Feature::allAreInactive(['new-api', 'site-redesign']);

// 是否有任意功能未启用
Feature::someAreInactive(['new-api', 'site-redesign']);

条件执行(when / unless

when 方法只在功能启用时执行闭包。
return Feature::when(NewApi::class,
    fn () => $this->resolveNewApiResponse($request),
    fn () => $this->resolveLegacyApiResponse($request),
);
unlesswhen 相反,在功能未启用时执行第一个闭包。
return Feature::unless(NewApi::class,
    fn () => $this->resolveLegacyApiResponse($request),
    fn () => $this->resolveNewApiResponse($request),
);

HasFeatures trait

User 模型上添加 HasFeatures trait 后,就可以直接从模型上检查功能。
use Laravel\Pennant\Concerns\HasFeatures;

class User extends Authenticatable
{
    use HasFeatures;
}
if ($user->features()->active('new-api')) {
    // ...
}

// 获取值
$value = $user->features()->value('purchase-button');

// 条件执行
$user->features()->when('new-api',
    fn () => /* ... */,
    fn () => /* ... */,
);

Blade 指令

Blade 模板中可以使用 @feature 指令。
@feature('site-redesign')
    {{-- 'site-redesign' 启用时 --}}
@else
    {{-- 'site-redesign' 未启用时 --}}
@endfeature

@featureany(['site-redesign', 'beta'])
    {{-- 任一功能启用时 --}}
@endfeatureany

中间件

EnsureFeaturesAreActive 中间件可以为路由声明必须启用的功能。若未启用会返回 400 Bad Request
use Laravel\Pennant\Middleware\EnsureFeaturesAreActive;

Route::get('/api/servers', function () {
    // ...
})->middleware(EnsureFeaturesAreActive::using('new-api', 'servers-api'));
要自定义响应,使用 whenInactive 方法。
EnsureFeaturesAreActive::whenInactive(
    function (Request $request, array $features) {
        return new Response(status: 403);
    }
);

内存缓存

Pennant 会在同一请求内把功能结果缓存到内存中。即使多次判断同一个功能,也不会产生额外的数据库查询。 要手动清空缓存,使用 flushCache
Feature::flushCache();

作用域(Scope)

指定作用域

默认情况下作用域是已认证用户,可以通过 for 方法指定任意作用域。
// 检查特定用户
Feature::for($user)->active('new-api');

// 检查团队
Feature::for($user->team)->active('billing-v2');
按团队管理功能开关的示例:
Feature::define('billing-v2', function (Team $team) {
    if ($team->created_at->isAfter(new Carbon('1st Jan, 2023'))) {
        return true;
    }

    if ($team->created_at->isAfter(new Carbon('1st Jan, 2019'))) {
        return Lottery::odds(1 / 100);
    }

    return Lottery::odds(1 / 1000);
});

自定义默认作用域

可以通过 Feature::resolveScopeUsing 修改默认作用域。
Feature::resolveScopeUsing(fn ($driver) => Auth::user()?->team);
设置后不加 for 时会使用默认作用域。
Feature::active('billing-v2');
// 等同于
Feature::for($user->team)->active('billing-v2');

Nullable 作用域

当作用域为 null(未认证路由、Artisan 命令等)时,如果功能定义不支持 null,会自动返回 false。若需要支持 null,请在定义中使用可空类型。
Feature::define('new-api', fn (User|null $user) => match (true) {
    $user === null => true,
    $user->isInternalTeamMember() => true,
    $user->isHighTrafficCustomer() => false,
    default => Lottery::odds(1 / 100),
});

富功能值(Rich Feature Values)

功能不只可以返回布尔值。例如 A/B 测试中控制按钮颜色:
Feature::define('purchase-button', fn (User $user) => Arr::random([
    'blue-sapphire',
    'seafoam-green',
    'tart-orange',
]));
取值时使用 value 方法。
$color = Feature::value('purchase-button');
Blade 中可以基于值进行分支:
@feature('purchase-button', 'blue-sapphire')
    {{-- blue-sapphire 启用 --}}
@elsefeature('purchase-button', 'seafoam-green')
    {{-- seafoam-green 启用 --}}
@elsefeature('purchase-button', 'tart-orange')
    {{-- tart-orange 启用 --}}
@endfeature
使用富值时,除 false 之外的所有值都视为启用。
将富值传入 when 时,第一个闭包会收到该值。
Feature::when('purchase-button',
    fn ($color) => /* $color 是当前值 */,
    fn () => /* 未启用 */,
);

一次性获取多个功能

values 方法可以一次性获取多个功能的值。
Feature::values(['billing-v2', 'purchase-button']);

// [
//     'billing-v2' => false,
//     'purchase-button' => 'blue-sapphire',
// ]
all 方法返回已定义的所有功能的值。
Feature::all();
若希望 all 的结果包含基于类的功能,在服务提供者中调用 discover
Feature::discover();
这样 app/Features 目录下的所有功能类都会被注册。

Eager Loading

在循环里检查功能可能带来性能问题。可以通过 load 提前一次性取值。
// NG:每次循环都会产生数据库查询
foreach ($users as $user) {
    if (Feature::for($user)->active('notifications-beta')) {
        $user->notify(new RegistrationSuccess);
    }
}

// OK:提前批量获取
Feature::for($users)->load(['notifications-beta']);

foreach ($users as $user) {
    if (Feature::for($user)->active('notifications-beta')) {
        $user->notify(new RegistrationSuccess);
    }
}
只加载未取过的值时使用 loadMissing
Feature::for($users)->loadMissing([
    'new-api',
    'purchase-button',
    'notifications-beta',
]);

更新值

手动更新

activate / deactivate 用于打开或关闭功能。
// 在默认作用域下启用
Feature::activate('new-api');

// 在特定作用域下禁用
Feature::for($user->team)->deactivate('billing-v2');

// 设置富值
Feature::activate('purchase-button', 'seafoam-green');
若要让保存的值“被遗忘”,使用 forget。下次判断时会重新从定义计算。
Feature::forget('purchase-button');

批量更新

activateForEveryone / deactivateForEveryone 会一次性作用于存储中的所有作用域。
Feature::activateForEveryone('new-api');
Feature::activateForEveryone('purchase-button', 'seafoam-green');
Feature::deactivateForEveryone('new-api');

清除功能(purge)

当你把某功能从代码中移除或修改了定义时,可以从存储中清除对应值(purge)。
// 清除单个功能
Feature::purge('new-api');

// 清除多个功能
Feature::purge(['new-api', 'purchase-button']);

// 清除全部
Feature::purge();
也可以通过 Artisan 命令 purge,非常适合部署流水线。
php artisan pennant:purge new-api

# 多个功能
php artisan pennant:purge new-api purchase-button

# 除指定功能外全部 purge
php artisan pennant:purge --except=new-api --except=purchase-button

# 除已在服务提供者注册的功能外全部 purge
php artisan pennant:purge --except-registered

测试

重新定义功能

测试中可以通过 Feature::define 重新定义,控制返回值。
tab=Pest
use Laravel\Pennant\Feature;

test('it can control feature values', function () {
    Feature::define('purchase-button', 'seafoam-green');

    expect(Feature::value('purchase-button'))->toBe('seafoam-green');
});
tab=PHPUnit
use Laravel\Pennant\Feature;

public function test_it_can_control_feature_values(): void
{
    Feature::define('purchase-button', 'seafoam-green');

    $this->assertSame('seafoam-green', Feature::value('purchase-button'));
}
基于类的功能同样如此。
tab=Pest
test('it can control feature values', function () {
    Feature::define(NewApi::class, true);

    expect(Feature::value(NewApi::class))->toBeTrue();
});
tab=PHPUnit
use App\Features\NewApi;

public function test_it_can_control_feature_values(): void
{
    Feature::define(NewApi::class, true);

    $this->assertTrue(Feature::value(NewApi::class));
}

测试用存储配置

可以在 phpunit.xml 中通过环境变量指定测试用存储。
<?xml version="1.0" encoding="UTF-8"?>
<phpunit colors="true">
    <php>
        <env name="PENNANT_STORE" value="array"/>
    </php>
</phpunit>

自定义驱动

如果已有驱动不能满足需求,可以自定义驱动。实现 Laravel\Pennant\Contracts\Driver 接口即可。
<?php

namespace App\Extensions;

use Laravel\Pennant\Contracts\Driver;

class RedisFeatureDriver implements Driver
{
    public function define(string $feature, callable $resolver): void {}
    public function defined(): array {}
    public function getAll(array $features): array {}
    public function get(string $feature, mixed $scope): mixed {}
    public function set(string $feature, mixed $scope, mixed $value): void {}
    public function setForAllScopes(string $feature, mixed $value): void {}
    public function delete(string $feature, mixed $scope): void {}
    public function purge(array|null $features): void {}
}
在服务提供者的 boot 方法中通过 extend 注册。
Feature::extend('redis', function (Application $app) {
    return new RedisFeatureDriver($app->make('redis'), $app->make('events'), []);
});
注册后即可在 config/pennant.php 中使用。
'stores' => [
    'redis' => [
        'driver' => 'redis',
        'connection' => null,
    ],
],

小结

想做的事方法
安装composer require laravel/pennant
定义功能Feature::define('name', fn ($user) => ...)
判断功能Feature::active('name')
Blade 中判断@feature('name') ... @endfeature
更新值Feature::activate('name') / deactivate
对所有作用域生效Feature::activateForEveryone('name')
在测试中控制Feature::define('name', true) 重定义
从存储中 purgeFeature::purge('name')

下一步

调试与错误处理

了解应用异常处理与上报机制。

Laravel Pulse

引入应用性能监控仪表盘。
最后修改于 2026年7月13日