> ## 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 實務模式。介紹以團隊為單位的 scope、緊急 kill switch、Dark Launch、管理指令等，官方文件未提及的技巧。

基本使用方式請參考[指南頁面](/zh-TW/pennant)。本頁介紹官方文件中未提及的實務模式。

***

## 多租戶 SaaS 的團隊 Scope

若要以團隊（tenant）而非個人使用者為單位管理旗標，可將預設 scope 改為團隊。

```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` 方法）

正式環境發生錯誤時，可不回滾程式碼即刻停用該功能。在類別型 feature 加上 `before` 方法後，會先於儲存值進行檢查。

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

namespace App\Features;

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

class NewCheckout
{
    /**
     * 於正式環境發生錯誤時可用 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 指令即可自動化。

***

## Dark Launch（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());

                // 記錄差異到 log（不顯示給使用者）
                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);
    }
}
```

確認 log 沒有差異後，只要把旗標切為 `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 listener
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>

***

## 佇列 Job 中的 scope

佇列 Job 中沒有認證使用者，因此 feature 檢查可能行為出乎意料。請讓 Job 明確帶著 scope。

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

    public function handle(): void
    {
        // NG：無認證使用者，總會回傳 false
        // if (Feature::active('new-digest-layout')) { ... }

        // OK：明確傳入使用者
        if (Feature::for($this->user)->active('new-digest-layout')) {
            $this->sendNewLayout($this->user);
        } else {
            $this->sendLegacyLayout($this->user);
        }
    }
}
```

也可以在 Job dispatch 時就評估旗標並傳給 Job。

```php theme={null}
// 於 Controller 評估後傳給 Job
$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} 個 scope 中啟用");
    }
}
```

```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 的價值。

| 模式                   | 適用情境           |
| -------------------- | -------------- |
| 團隊 Scope             | 多租戶 SaaS       |
| `before` Kill Switch | 正式環境問題緊急處理     |
| 排程發佈                 | 指定日期時間的自動發佈    |
| Dark Launch          | 安全地在正式環境驗證新演算法 |
| `FeatureResolved` 事件 | 正確收集 A/B 測試結果  |
| Job 明確 scope         | 佇列環境下的正確旗標評估   |
| 管理用 Artisan 指令       | 不需部署的正式旗標操作    |
| `Name` 屬性            | 安全的類別重構        |

<Card title="Laravel Pennant 指南" icon="flag" href="/zh-TW/pennant">
  安裝到基本用法請參考指南頁面。
</Card>


## Related topics

- [PHP FFI](/zh-TW/advanced/ffi.md)
- [Laravel Pennant](/zh-TW/pennant.md)
- [Macroable trait](/zh-TW/advanced/macroable.md)
- [Laravel 套件開發](/zh-TW/advanced/package-development.md)
- [Laravel Console Starter](/zh-TW/packages/laravel-console-starter/index.md)
