> ## 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 管理功能旗標（Feature Flags）的方法。廣泛涵蓋 Feature 定義、判斷、Scope 設定與測試。

## 什麼是 Laravel Pennant

[Laravel Pennant](https://github.com/laravel/pennant) 是簡潔輕量的功能旗標（Feature Flag）套件。功能旗標可用來階段性推出新功能、進行 A/B 測試，或補充 trunk-based 開發。

### 什麼是功能旗標

```mermaid theme={null}
flowchart TD
    A["請求"] --> B{"Feature::active('new-api')"}
    B -->|true| C["新 API 處理"]
    B -->|false| D["原 API 處理"]
    C --> E["回應"]
    D --> E
```

透過功能旗標，可以將程式碼的部署與發佈分離。程式碼可先部署到正式環境，再以設定控制功能的開／關。

***

## 安裝

<Steps>
  <Step title="安裝套件">
    使用 Composer 安裝 Pennant。

    ```bash theme={null}
    composer require laravel/pennant
    ```
  </Step>

  <Step title="公開設定檔與 migration">
    使用 `vendor:publish` Artisan 指令公開檔案。

    ```bash theme={null}
    php artisan vendor:publish --provider="Laravel\Pennant\PennantServiceProvider"
    ```

    這會在 `config/pennant.php` 產生設定，並在 `database/migrations` 產生 migration 檔案。
  </Step>

  <Step title="執行 migration">
    建立 Pennant 用來儲存 feature flag 值的 `features` 資料表。

    ```bash theme={null}
    php artisan migrate
    ```
  </Step>
</Steps>

***

## 設定

可在 `config/pennant.php` 設定所使用的儲存 driver。Pennant 支援兩種 driver。

| Driver     | 說明                 |
| ---------- | ------------------ |
| `database` | 將值永久儲存到關聯式資料庫（預設）  |
| `array`    | 儲存於記憶體（適用於測試或暫時使用） |

```php theme={null}
// config/pennant.php
'default' => env('PENNANT_STORE', 'database'),
```

***

## Feature 的定義

### 基於 closure 的定義

Feature 以 `Feature` facade 的 `define` 方法定義。通常在服務提供者的 `boot` 方法中定義。closure 會收到「scope」（通常是已認證的使用者）。

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

此 feature 的邏輯如下：

* 內部團隊成員一律 ON
* 高流量客戶為 OFF
* 其他人以 1% 機率 ON

feature 首次被檢查時，closure 的結果會存到儲存 driver。之後就會使用已儲存的值。

<Info>
  若定義只回傳 Lottery，可以省略 closure。

  ```php theme={null}
  Feature::define('site-redesign', Lottery::odds(1, 1000));
  ```
</Info>

### 基於類別的定義

Pennant 也支援類別式 feature 定義。使用類別方式時不需要在服務提供者中註冊。

```bash theme={null}
php artisan pennant:feature NewApi
```

產生的類別會放在 `app/Features` 目錄。只要實作 `resolve` 方法即可。

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

namespace App\Features;

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

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

#### 自訂儲存名稱

預設會儲存完整類別名稱。可用 `Name` 屬性自訂名稱。

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

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

#### 攔截 feature 檢查（`before` 方法）

類別式 feature 可定義 `before` 方法。這個方法會在從儲存取得值之前於記憶體中執行；若回傳非 `null` 的值，就會使用該值。

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

<Tip>
  `before` 方法適合在發生 bug 時緊急停用功能，或安排在指定日期時間才進行 rollout 的情境。
</Tip>

***

## Feature 的檢查

### `Feature::active()` / `Feature::inactive()`

可用 `active` 方法確認 feature 是否啟用。預設會對目前已認證的使用者進行檢查。

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

if (Feature::active('new-api')) {
    // 使用新 API 的處理
}
```

若是類別式 feature，傳入類別名稱：

```php theme={null}
use App\Features\NewApi;

if (Feature::active(NewApi::class)) {
    // ...
}
```

也提供其他便利方法：

```php theme={null}
// 所有 feature 是否皆啟用
Feature::allAreActive(['new-api', 'site-redesign']);

// 是否有任一 feature 啟用
Feature::someAreActive(['new-api', 'site-redesign']);

// feature 是否停用
Feature::inactive('new-api');

// 所有 feature 是否皆停用
Feature::allAreInactive(['new-api', 'site-redesign']);

// 是否有任一 feature 停用
Feature::someAreInactive(['new-api', 'site-redesign']);
```

### 條件式執行（`when` / `unless`）

使用 `when` 方法可在 feature 啟用時執行 closure。

```php theme={null}
return Feature::when(NewApi::class,
    fn () => $this->resolveNewApiResponse($request),
    fn () => $this->resolveLegacyApiResponse($request),
);
```

`unless` 為 `when` 的相反，會在 feature 停用時執行第一個 closure。

```php theme={null}
return Feature::unless(NewApi::class,
    fn () => $this->resolveLegacyApiResponse($request),
    fn () => $this->resolveNewApiResponse($request),
);
```

### `HasFeatures` trait

在 `User` model 加入 `HasFeatures` trait，即可直接從 model 檢查 feature。

```php theme={null}
use Laravel\Pennant\Concerns\HasFeatures;

class User extends Authenticatable
{
    use HasFeatures;
}
```

```php theme={null}
if ($user->features()->active('new-api')) {
    // ...
}

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

// 條件式執行
$user->features()->when('new-api',
    fn () => /* ... */,
    fn () => /* ... */,
);
```

### Blade 指示詞

在 Blade 樣板中可使用 `@feature` 指示詞。

```blade theme={null}
@feature('site-redesign')
    {{-- 'site-redesign' 啟用時 --}}
@else
    {{-- 'site-redesign' 停用時 --}}
@endfeature

@featureany(['site-redesign', 'beta'])
    {{-- 有任一啟用時 --}}
@endfeatureany
```

### Middleware

使用 `EnsureFeaturesAreActive` middleware，可指定路由的存取需要哪些 feature。當 feature 停用時，會回傳 `400 Bad Request`。

```php theme={null}
use Laravel\Pennant\Middleware\EnsureFeaturesAreActive;

Route::get('/api/servers', function () {
    // ...
})->middleware(EnsureFeaturesAreActive::using('new-api', 'servers-api'));
```

要自訂回應，可使用 `whenInactive` 方法。

```php theme={null}
EnsureFeaturesAreActive::whenInactive(
    function (Request $request, array $features) {
        return new Response(status: 403);
    }
);
```

### 記憶體快取

Pennant 會在單一請求內將 feature 的結果快取到記憶體。即使多次檢查同一個 feature flag，也不會產生額外的 DB 查詢。

若需要手動清除快取，可使用 `flushCache` 方法。

```php theme={null}
Feature::flushCache();
```

***

## Scope

### 指定 scope

預設以已認證的使用者作為 scope，可用 `for` 方法指定任意 scope。

```php theme={null}
// 對特定使用者檢查
Feature::for($user)->active('new-api');

// 對團隊檢查
Feature::for($user->team)->active('billing-v2');
```

依團隊管理 feature 的範例：

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

### 自訂預設 scope

可透過 `Feature::resolveScopeUsing` 自訂預設 scope。

```php theme={null}
Feature::resolveScopeUsing(fn ($driver) => Auth::user()?->team);
```

設定後省略 `for` 時就會採用預設 scope。

```php theme={null}
Feature::active('billing-v2');
// 等同於下列
Feature::for($user->team)->active('billing-v2');
```

### 可為 null 的 Scope

若 scope 為 `null`（例如未認證路由、Artisan 指令等），當 feature 定義未支援 null，會自動回傳 `false`。要處理 null 時，請以 nullable 型別定義。

```php theme={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 值

feature 也可回傳 boolean 以外的值。例如在 A/B 測試中控制按鈕顏色。

```php theme={null}
Feature::define('purchase-button', fn (User $user) => Arr::random([
    'blue-sapphire',
    'seafoam-green',
    'tart-orange',
]));
```

使用 `value` 方法取得值。

```php theme={null}
$color = Feature::value('purchase-button');
```

在 Blade 中也能根據值進行條件分支。

```blade theme={null}
@feature('purchase-button', 'blue-sapphire')
    {{-- blue-sapphire 啟用 --}}
@elsefeature('purchase-button', 'seafoam-green')
    {{-- seafoam-green 啟用 --}}
@elsefeature('purchase-button', 'tart-orange')
    {{-- tart-orange 啟用 --}}
@endfeature
```

<Info>
  使用 rich 值時，除 `false` 以外的所有值都會被視為啟用。
</Info>

當 `when` 方法接收 rich 值時，第一個 closure 會收到該值。

```php theme={null}
Feature::when('purchase-button',
    fn ($color) => /* $color 內含值 */,
    fn () => /* 停用時 */,
);
```

***

## 取得多個 feature

使用 `values` 方法可一次取得多個 feature 的值。

```php theme={null}
Feature::values(['billing-v2', 'purchase-button']);

// [
//     'billing-v2' => false,
//     'purchase-button' => 'blue-sapphire',
// ]
```

使用 `all` 方法可取得所有已定義 feature 的值。

```php theme={null}
Feature::all();
```

若要將類別式 feature 也納入 `all` 的結果，在服務提供者呼叫 `discover`。

```php theme={null}
Feature::discover();
```

這會註冊 `app/Features` 目錄下所有的 feature 類別。

***

## Eager Loading

在迴圈中檢查 feature 時可能會發生效能問題。可用 `load` 方法預先取得值來解決。

```php theme={null}
// NG：每次迴圈都會發生 DB 查詢
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`。

```php theme={null}
Feature::for($users)->loadMissing([
    'new-api',
    'purchase-button',
    'notifications-beta',
]);
```

***

## 更新值

### 手動更新

可用 `activate` / `deactivate` 方法切換 feature 的開／關。

```php theme={null}
// 以預設 scope 啟用
Feature::activate('new-api');

// 對特定 scope 停用
Feature::for($user->team)->deactivate('billing-v2');

// 設定 rich 值
Feature::activate('purchase-button', 'seafoam-green');
```

若要忘掉已儲存的值，可用 `forget` 方法。下次檢查時會由定義重新評估。

```php theme={null}
Feature::forget('purchase-button');
```

### 批次更新

可用 `activateForEveryone` / `deactivateForEveryone` 對儲存中的所有 scope 進行批次套用。

```php theme={null}
Feature::activateForEveryone('new-api');
Feature::activateForEveryone('purchase-button', 'seafoam-green');
Feature::deactivateForEveryone('new-api');
```

### Feature 的清除（purge）

若 feature 已從應用移除或定義有變更，可將值從儲存中清除（purge）。

```php theme={null}
// 清除單一 feature
Feature::purge('new-api');

// 清除多個 feature
Feature::purge(['new-api', 'purchase-button']);

// 清除所有 feature
Feature::purge();
```

也可使用 Artisan 指令清除。與部署流程結合會很方便。

```bash theme={null}
php artisan pennant:purge new-api

# 多個指定
php artisan pennant:purge new-api purchase-button

# 清除指定 feature 以外的所有 feature
php artisan pennant:purge --except=new-api --except=purchase-button

# 清除未於服務提供者註冊的 feature
php artisan pennant:purge --except-registered
```

***

## 測試

### 重新定義 feature

在測試中可用 `Feature::define` 重新定義 feature，控制其回傳值。

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

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

類別式 feature 也可以同樣處理。

```php tab=Pest theme={null}
test('it can control feature values', function () {
    Feature::define(NewApi::class, true);

    expect(Feature::value(NewApi::class))->toBeTrue();
});
```

```php tab=PHPUnit theme={null}
use App\Features\NewApi;

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

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

### 測試用 store 設定

可在 `phpunit.xml` 的環境變數指定測試中使用的 store。

```xml theme={null}
<?xml version="1.0" encoding="UTF-8"?>
<phpunit colors="true">
    <php>
        <env name="PENNANT_STORE" value="array"/>
    </php>
</phpunit>
```

***

## 自訂 Driver

若既有 driver 不符合需求，可以建立自訂 driver。實作 `Laravel\Pennant\Contracts\Driver` 介面即可。

```php theme={null}
<?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` 進行註冊。

```php theme={null}
Feature::extend('redis', function (Application $app) {
    return new RedisFeatureDriver($app->make('redis'), $app->make('events'), []);
});
```

註冊後即可在 `config/pennant.php` 指定 driver。

```php theme={null}
'stores' => [
    'redis' => [
        'driver' => 'redis',
        'connection' => null,
    ],
],
```

***

## 總結

| 需求           | 方法                                           |
| ------------ | -------------------------------------------- |
| 安裝 feature   | `composer require laravel/pennant`           |
| 定義 feature   | `Feature::define('name', fn ($user) => ...)` |
| 檢查 feature   | `Feature::active('name')`                    |
| 在 Blade 中檢查  | `@feature('name') ... @endfeature`           |
| 更新值          | `Feature::activate('name')` / `deactivate`   |
| 對所有 scope 套用 | `Feature::activateForEveryone('name')`       |
| 於測試中控制       | 以 `Feature::define('name', true)` 重新定義       |
| 從儲存中清除       | `Feature::purge('name')`                     |

## 後續步驟

<Columns cols={2}>
  <Card title="除錯與錯誤處理" icon="circle-x" href="/zh-TW/error-handling">
    了解應用程式的例外處理與回報機制。
  </Card>

  <Card title="Laravel Pulse" icon="chart-line" href="/zh-TW/pulse">
    導入應用程式效能監控儀表板。
  </Card>
</Columns>


## Related topics

- [Laravel Pennant 實務案例](/zh-TW/blog/laravel-pennant.md)
- [部落格](/zh-TW/blog/index.md)
- [博客](/zh-CN/blog/index.md)
- [Laravel Boost](/zh-TW/boost.md)
- [Laravel Telescope](/zh-TW/telescope.md)
