<? phpukraine СТАТТІ
⌕Пошук по платформі
LARAVEL 29 вересня 2026 · 7 хв читання

Laravel Pennant: фіче-флаги без зовнішнього сервісу

Гілка живе третій тиждень, кожен rebase довший за попередній, а мержити страшно саме тому, що зміна велика. Pennant дозволяє везти незакінчений код у main і на прод, вмикаючи його поштучно: визначення фічі, скоуп, лотерея для поступового викочування, тести в обох станах і найважча частина - прибирання флага, коли він відпрацював.

РP
Редакція phpukraine
Редакція платформи

Довга гілка псує все навколо себе. У main за три тижні поїхали міграції, зміни в моделі вакансії і новий layout каталогу, тому кожен наступний rebase болючіший за попередній. Мержити страшно рівно тому, що зміна велика і жодного дня не бачила продакшену.

Фіче-флаг розриває звʼязок між «код на сервері» і «код виконується». Гілка живе день чи два, їде в main, деплоїться разом з усім іншим, але працює лише для тих, кого ви явно ввімкнули. Laravel Pennant дає для цього мінімум без зовнішнього сервісу: пакет, таблиця features у вашій же базі і фасад Feature.

composer require laravel/pennant
php artisan vendor:publish --provider="Laravel\Pennant\PennantServiceProvider"
php artisan migrate

Далі все на Laravel 13, Pennant 1.x і PHP 8.4. Драйвер задається в config/pennant.php або через PENNANT_STORE: database пише в таблицю, array тримає значення в памʼяті одного запиту.

Деплой окремо від релізу

Trunk-based розробка тримається на тому, що конфлікти малі за визначенням, а частота комітів у main тут лише наслідок. Умова одна: незакінчена робота має безпечно доїжджати до проду. Флаг саме це й дозволяє, бо нова гілка коду існує, але недосяжна.

Побічний ефект корисніший за основний. Коли вимкнення релізу коштує один рядок в адмінці замість rollback-деплою, змінюється ставлення до ризику: сумнівну зміну простіше спробувати на десятьох людях, ніж обговорювати два тижні. Платити за це доводиться дисципліною прибирання, про неї в кінці.

Визначення фічі і скоуп

Найпростіша форма це замикання в AppServiceProvider::boot():

use Laravel\Pennant\Feature;

Feature::define('job-search-v2', fn (User $user) => $user->is_staff);

Для чогось складнішого за один вираз є клас: php artisan pennant:feature JobSearchV2 створює app/Features/JobSearchV2.php.

namespace App\Features;

use App\Models\User;

class JobSearchV2
{
    public function resolve(User $user): mixed
    {
        return $user->is_staff;
    }
}

Імʼя такої фічі це її FQCN, якщо не задати public string $name. Перевірка виглядає однаково в обох випадках: Feature::active(JobSearchV2::class), Feature::inactive(), Feature::when($feature, $whenActive, $whenInactive). У Blade є @feature('job-search-v2') ... @else ... @endfeature, на маршрутах middleware EnsureFeaturesAreActive::using('job-search-v2'), який за замовчуванням віддає 400 і переналаштовується через EnsureFeaturesAreActive::whenInactive().

Скоуп це те, для кого рахується значення. За замовчуванням береться автентифікований користувач, але прив'язати можна що завгодно:

Feature::for($company)->active('bulk-job-import');

Дві деталі тут регулярно обертаються багами. Перша: якщо в сигнатурі resolve() тип не nullable, а скоуп порожній (гість, консольна команда, черга), Pennant не викликає замикання і повертає false, а в лог подій летить UnexpectedNullScopeEncountered. Для фіч, які мають працювати для анонімів, тип має бути ?User. Друга: значення резолвиться один раз і лягає в таблицю features парою name + scope. Після зміни визначення старі збережені значення залишаються як були, доки ви не викличете Feature::forget() для скоупа або не почистите фічу цілком.

Окремо про архітектуру. Feature::active() у доменному коді означає імпорт Illuminate, що в цьому проєкті ловить архітектурний тест. Читати флаг доречно на межі: у контролері, у Livewire-компоненті або в біндінгу контейнера, де він обирає реалізацію.

$this->app->bind(JobSearch::class, fn () => Feature::active(JobSearchV2::class)
    ? new IndexedJobSearch(...)
    : new SqlJobSearch(...));

Домен у цій схемі не знає, що флаг взагалі існує, і його тести не залежать від Pennant.

Викочування на частку користувачів

Pennant вміє повертати з resolve() не тільки булеве значення. Illuminate\Support\Lottery розігрується один раз, і результат зберігається, тому користувач не стрибатиме від запиту до запиту між старою і новою версією:

use Illuminate\Support\Lottery;

public function resolve(User $user): mixed
{
    return match (true) {
        $user->is_staff => true,
        $user->hasActiveJobs() => false,
        default => Lottery::odds(1, 10),
    };
}

Порядок гілок тут і є політикою викочування: свої бачать нове завжди, роботодавці з активними вакансіями поки що не бачать нічого, решта потрапляє в десятивідсоткову вибірку.

Підняття частки з 10% до 25% має неочевидний бік. Зміна odds подіє лише на тих, для кого значення ще не збережене. Щоб перерозіграти лотерею для всіх, треба скинути збережені значення (php artisan pennant:purge job-search-v2), і тоді частина людей, які вже звикли до нового каталогу, повернеться до старого. Практичніший шлях: не чіпати вже видані true, а нарощувати шанс для нових скоупів, і лише на фінальному кроці зробити Feature::activateForEveryone('job-search-v2').

Дзеркальний deactivateForEveryone() оновлює вже збережені значення, але не скасовує визначення: новий користувач пройде через resolve() і знову може виграти лотерею. Якщо потрібен справжній kill switch, вимикайте визначення (наприклад, читанням конфіга всередині resolve()), а не покладайтесь на масове оновлення.

Значення не зобовʼязане бути булевим. Feature::value('job-card-layout') поверне рядок, і resolve() може віддавати 'compact', 'wide' або false, і це вже A/B-варіанти в тій самій механіці.

Список користувачів в адмінці перетворює перевірку флага на N+1, бо кожне звернення до нового скоупа це окремий запит або вставка. Pennant має для цього явне довантаження:

$users = User::query()->limit(50)->get();

Feature::for($users)->loadMissing(['job-search-v2', 'bulk-job-import']);

Після цього перевірки в циклі йдуть з памʼяті. У межах одного запиту значення так чи так кешуються; якщо ви змінили флаг посеред запиту і хочете побачити нове значення, потрібен Feature::flushCache().

Тести з увімкненими й вимкненими фічами

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

use App\Features\JobSearchV2;
use Laravel\Pennant\Feature;

it('renders the job catalogue in both variants', function (bool $enabled) {
    Feature::define(JobSearchV2::class, $enabled);

    $this->actingAs(User::factory()->create())
        ->get(route('jobs.index'))
        ->assertOk()
        ->assertSee('Вакансії');
})->with([true, false]);

Feature::define() у тесті перекриває визначення з провайдера, тому лотерея і перевірки на is_staff не заважають. Для тестів, які працюють з конкретним користувачем, є Feature::for($user)->activate('job-search-v2').

У phpunit.xml варто прибити драйвер до памʼяті:

<env name="PENNANT_STORE" value="array"/>

Інакше кожна перевірка флага це запис у таблицю features, а між тестами лишається стан, який ви не задавали. З array проблеми немає, як немає й перевірки, що ваша фіча взагалі переживає збереження в базу. Один тест на database-стор для складного скоупа (наприклад, коли скоуп це компанія, а не користувач) закриває цю дірку.

Гілка з вимкненим флагом важливіша за ввімкнену. Вона працює в проді всі дні викочування, і про неї ж забувають, коли нова реалізація вже здається готовою.

Як прибирати старі флаги

Флаг, який прожив пів року, перестає бути флагом і стає конфігом, тільки гіршим: про нього ніхто не памʼятає, обидві гілки коду тухнуть, а тести мовчки ганяють їх разом. Термін життя треба призначати одразу, у PHPDoc самої фічі або в тому ж тикеті, де вона зʼявилась.

Порядок зняття зворотний до порядку введення. Спершу з коду йдуть перевірки і мертва гілка, потім визначення, і лише в кінці дані:

php artisan pennant:purge job-search-v2

Якщо переставити кроки місцями і видалити визначення раніше за перевірки, Feature::active() почне мовчки повертати false. Замість помилки ви отримаєте несподівано вимкнену фічу в проді. Страхує від цього подія:

use Illuminate\Support\Facades\Event;
use Laravel\Pennant\Events\UnknownFeatureResolved;

Event::listen(function (UnknownFeatureResolved $event) {
    Log::error("Resolving unknown feature [{$event->feature}].");
});

Для регулярного прибирання є php artisan pennant:purge --except-registered: команда лишає в таблиці тільки ті фічі, які зараз є в коді, і виносить решту. Її можна тримати в деплої разом з міграціями, тоді рядки видалених флагів не накопичуються роками.

Межа корисності проходить по даних. Флаг перемикає код, але не вміє перемикати схему: якщо стара і нова гілки пишуть у різні колонки, то під час викочування заповнюються обидві, і план відкату мусить існувати на рівні даних, а не флага. Те саме з чергами: джоба, поставлена ввімкненою гілкою, виконається вже після того, як ви фічу вимкнули. Для таких випадків флаг перевіряється в момент постановки, а в payload кладеться вже прийняте рішення.

ПИШЕТЕ ПРО PHP?Опублікуйте розбір або історію з проєкту на платформіРедактор із чеклістом, редактура, авторська сторінка. Републікація з блогу отримує canonical на оригінал. Відкрити редактор →
РP
Редакція phpukraine
Редакція платформи
Матеріали, які готує команда платформи на основі власних даних: каталогу вакансій, зарплатного звіту й банку питань. Кожна цифра в них рахується з бази, а не береться з голови.
оновлено 3 жовтня 2026 · ліцензія CC-BY-SA-4.0
ДАЛІ ПО ТЕМІ
ЧИТАТИ ДАЛІ
← Усі статті