메인 콘텐츠로 건너뛰기
기본 사용법은 가이드 페이지를 참고하세요. 이 페이지에서는 공식 문서에 실려 있지 않은 실전 패턴을 소개합니다.

멀티 테넌트 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');

긴급 킬 스위치 (before 메서드 활용)

프로덕션에서 버그가 발각되었을 때, 코드 롤백 없이 즉시 기능을 무효화할 수 있습니다. 클래스 기반 피처에 before 메서드를 추가하면 스토리지의 값보다 먼저 체크가 실행됩니다.
<?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를 건드리지 않고 기능을 멈출 수 있습니다. 배포 불필요한 킬 스위치입니다.
beforenull을 반환하면 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 이벤트는 피처의 값이 처음으로 해결되었을 때만 발화합니다. 이것을 사용하여 사용자의 배리언트 할당을 기록합니다.
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가 적합합니다.

큐잉된 잡에서의 스코프

큐의 잡에서는 인증된 사용자가 존재하지 않으므로, 피처 체크가 예상과 다른 동작을 할 수 있습니다. 잡에 스코프를 명시적으로 가지게 하세요.
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);
        }
    }
}
잡의 디스패치 시에 플래그를 평가하여 잡에 전달하는 방법도 있습니다.
// 컨트롤러에서 평가하여 잡에 전달
$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

피처 이름의 안전한 리팩토링 (Name 어트리뷰트)

클래스 기반 피처를 리네임할 때, 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 킬 스위치프로덕션 버그의 긴급 대응
스케줄 롤아웃일시 지정 릴리스 자동화
다크 런치안전한 새 알고리즘의 프로덕션 검증
FeatureResolved 이벤트A/B 테스트 결과의 정확한 수집
잡의 명시적 스코프큐 환경에서의 정확한 플래그 평가
관리 Artisan 명령배포 없는 프로덕션 플래그 조작
Name 어트리뷰트안전한 클래스 리팩토링

Laravel Pennant 가이드

설치부터 기본 사용법까지는 가이드 페이지를 참고하세요.
마지막 수정일 2026년 7월 13일