Laravel Pennant: feature flags в Laravel-приложениях на практике — PROG-TIME

Laravel Pennant: feature flags в Laravel-приложениях на практике

03.06.2026

Выкатывать новую функциональность сразу на всех пользователей — рискованно. Если что-то пойдёт не так, придётся срочно откатывать релиз. Feature flags (фиче-флаги) решают эту проблему: код новой функции уже в продакшене, но включается она постепенно — сначала для команды, потом для 1% пользователей, потом для всех. У Laravel для этого есть официальный пакет — Laravel Pennant. Лёгкий, без лишних зависимостей и сторонних сервисов: все значения флагов хранятся в вашей же базе данных. Разберём установку и основные сценарии работы на практике.

Установка и настройка

Ставим пакет через Composer:

composer require laravel/pennant

Публикуем конфиг и миграции, затем запускаем миграции — Pennant создаст таблицу features, в которой будет хранить рассчитанные значения флагов:

php artisan vendor:publish --provider="Laravel\Pennant\PennantServiceProvider"
php artisan migrate

В конфиге config/pennant.php выбирается драйвер хранения. Из коробки их два: database (по умолчанию, значения сохраняются в БД) и array (в памяти, на время запроса — удобно для тестов). При необходимости можно написать свой драйвер, например под Redis.

Определяем первый фиче-флаг

Флаги объявляются через фасад Feature, обычно в boot() сервис-провайдера. Метод define принимает имя фичи и замыкание, которое вычисляет её начальное значение. В замыкание приходит «scope» — чаще всего текущий авторизованный пользователь:

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

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 к 100. Важный момент: замыкание выполняется только при первой проверке флага для конкретного пользователя. Результат сохраняется в таблицу features, и дальше Pennant читает его из хранилища — пользователь не будет «мигать» между вариантами от запроса к запросу.

Class-based фичи

Для крупных фич удобнее отдельный класс — его не нужно регистрировать в провайдере. Генерируем заготовку командой:

php artisan pennant:feature NewApi

Класс появится в app/Features. В нём достаточно описать метод resolve:

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,
            default => Lottery::odds(1 / 100),
        };
    }
}

У классовых фич есть мощная возможность — метод before. Он выполняется в памяти до обращения к хранилищу, и если вернёт не-null, это значение перекроет сохранённое. Так можно мгновенно отключить фичу для всех (например, при найденном баге), не потеряв уже рассчитанные значения, а после исправления — включить обратно.

Проверяем флаги в коде

Основной метод — Feature::active(). По умолчанию проверка идёт против текущего пользователя:

public function index(Request $request): Response
{
    return Feature::active('new-api')
        ? $this->resolveNewApiResponse($request)
        : $this->resolveLegacyApiResponse($request);
}

Есть и условное выполнение через when / unless, и проверка наборов флагов: allAreActive, someAreActive, inactive. Если добавить модели User трейт HasFeatures, проверки становятся ещё компактнее: $user->features()->active('new-api').

В Blade-шаблонах работает директива @feature:

@feature('site-redesign')
    <!-- новый дизайн -->
@else
    <!-- старый дизайн -->
@endfeature

А для защиты целых маршрутов есть middleware EnsureFeaturesAreActive — если фича выключена для пользователя, маршрут вернёт 400 (ответ можно кастомизировать через whenInactive):

use Laravel\Pennant\Middleware\EnsureFeaturesAreActive;

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

Scope: не только пользователи

Флаг можно проверять против любой сущности. Например, новую систему биллинга логично включать для целых команд, а не отдельных людей:

Feature::define('billing-v2', function (Team $team) {
    if ($team->created_at->isAfter(new Carbon('1st Jan, 2023'))) {
        return true;
    }

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

if (Feature::for($user->team)->active('billing-v2')) {
    return redirect('/billing/v2');
}

Через Feature::resolveScopeUsing() можно задать scope по умолчанию — и тогда везде писать просто Feature::active('billing-v2'). Учтите нюанс: если scope оказался null (артизан-команда, очередь, неавторизованный запрос), а определение фичи null не принимает, Pennant вернёт false. Для таких случаев объявляйте параметр как User|null и обрабатывайте null в логике.

Не только true/false: A/B-тесты

Флаг может хранить любое значение, а не только булево. Классический пример — тестируем три цвета кнопки покупки:

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

$color = Feature::value('purchase-button');

Каждому пользователю один раз выпадет свой вариант и закрепится за ним. Останется собрать метрики конверсии по вариантам — и зафиксировать победителя для всех: Feature::activateForEveryone('purchase-button', 'seafoam-green').

Управление значениями и производительность

Значениями можно управлять вручную: Feature::activate('new-api') и Feature::deactivate('new-api') переключают флаг для конкретного scope, activateForEveryone / deactivateForEveryone — для всех сразу. Метод Feature::forget() стирает сохранённое значение, и при следующей проверке оно рассчитается заново. Когда фича доехала до 100% и флаг больше не нужен, чистим хранилище:

php artisan pennant:purge new-api

Команду удобно встроить в деплой-пайплайн; флаг --except-registered удалит всё, кроме фич, зарегистрированных в провайдерах.

По производительности: внутри одного запроса Pennant кэширует результаты в памяти, повторные проверки не бьют в базу. Но если вы проверяете флаг в цикле по коллекции пользователей, получите по запросу на каждого. Решение — жадная загрузка:

Feature::for($users)->load(['notifications-beta']);

foreach ($users as $user) {
    if (Feature::for($user)->active('notifications-beta')) {
        $user->notify(new RegistrationSuccess);
    }
}

Тестирование

В тестах фичу проще всего переопределить — заданное значение перекроет логику из провайдера:

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

    $this->assertSame('seafoam-green', Feature::value('purchase-button'));
}

А в phpunit.xml стоит задать PENNANT_STORE=array, чтобы тесты не писали в базу.

Итоги

Laravel Pennant закрывает практически все типовые задачи фиче-флагов: постепенные раскатки через Lottery, A/B-тесты через rich values, мгновенный «рубильник» через before, флаги на уровне команд и любых других сущностей. При этом не нужен внешний SaaS — достаточно таблицы в вашей БД. Если вы практикуете trunk-based development или просто хотите перестать бояться релизов по пятницам, Pennant — самый естественный способ добавить фиче-флаги в Laravel-проект. Начните с одного флага на ближайшую крупную фичу — и дальше уже не остановитесь.