Backend-разработка

Feature Flags в продакшене: управление релизами с Laravel, Redis и CI/CD без даунтайма

Ruslan Ismailov Опубликовано 14 мин чтения
F

Введение: Feature Flags в 2026 году

Feature flags (или feature toggles) — это механизм, позволяющий включать и отключать функциональность приложения без изменения кода и без повторного деплоя. В 2026 году эта практика стала стандартом для команд, использующих trunk-based development: все разработчики работают в одной ветке, а незавершённый или рискованный код скрывается за флагами.

Связь с CI/CD очевидна: чем чаще вы деплоите, тем выше риск сломать продакшен. Feature flags разрывают это противоречие — код попадает в продакшен, но остаётся неактивным до явного включения. Это даёт постепенный релиз, безопасный откат и гибкое A/B тестирование без операций с БД или кодовой базой.

В этой статье мы построим полноценную систему управления флагами на Laravel и Redis, интегрируем её с GitHub Actions и разберём реальные сценарии использования.

Архитектура решения

Наша система состоит из трёх уровней:

  • Laravel — бизнес-логика, сервис-провайдер, middleware, Blade-директивы.
  • Redis — быстрое in-memory хранилище флагов с поддержкой TTL и горячего обновления без деплоя.
  • CI/CD (GitHub Actions) — автоматическое включение флагов после успешного деплоя.

Принцип работы прост: флаг — это ключ в Redis со значением, описывающим состояние (включён/выключен, процент пользователей, список групп). Laravel читает этот ключ при каждом запросе (с кешированием на уровне приложения) и принимает решение о показе функции. CI/CD-пайплайн управляет флагами через Redis CLI или HTTP API после успешного прохождения тестов.

Реализация Feature Flag сервиса на Laravel

Контракт и реализация

Начнём с интерфейса, чтобы сервис был легко тестируем и заменяем:

<?php

namespace App\Services\FeatureFlags;

interface FeatureFlagInterface
{
    public function isEnabled(string $flag, ?int $userId = null): bool;
    public function enable(string $flag): void;
    public function disable(string $flag): void;
    public function setRolloutPercentage(string $flag, int $percentage): void;
}

Теперь реализация на основе Redis:

<?php

namespace App\Services\FeatureFlags;

use Illuminate\Support\Facades\Redis;
use Illuminate\Support\Facades\Cache;

class RedisFeatureFlagService implements FeatureFlagInterface
{
    private const PREFIX = 'feature_flag:';
    private const CACHE_TTL = 30; // секунды

    public function isEnabled(string $flag, ?int $userId = null): bool
    {
        $data = $this->getFlagData($flag);

        if (empty($data) || $data['status'] === 'disabled') {
            return false;
        }

        if ($data['status'] === 'enabled') {
            return true;
        }

        // Canary: процентное включение по userId
        if ($data['status'] === 'canary' && $userId !== null) {
            $percentage = (int) ($data['percentage'] ?? 0);
            return ($userId % 100) < $percentage;
        }

        // Whitelist: включён только для конкретных пользователей
        if ($data['status'] === 'whitelist' && $userId !== null) {
            $list = json_decode($data['users'] ?? '[]', true);
            return in_array($userId, $list, true);
        }

        return false;
    }

    public function enable(string $flag): void
    {
        Redis::hset(self::PREFIX . $flag, 'status', 'enabled');
        $this->invalidateCache($flag);
    }

    public function disable(string $flag): void
    {
        Redis::hset(self::PREFIX . $flag, 'status', 'disabled');
        $this->invalidateCache($flag);
    }

    public function setRolloutPercentage(string $flag, int $percentage): void
    {
        Redis::hset(self::PREFIX . $flag, [
            'status'     => 'canary',
            'percentage' => max(0, min(100, $percentage)),
        ]);
        $this->invalidateCache($flag);
    }

    public function setWhitelist(string $flag, array $userIds): void
    {
        Redis::hset(self::PREFIX . $flag, [
            'status' => 'whitelist',
            'users'  => json_encode($userIds),
        ]);
        $this->invalidateCache($flag);
    }

    private function getFlagData(string $flag): array
    {
        return Cache::remember(
            'ff:' . $flag,
            self::CACHE_TTL,
            fn () => Redis::hgetall(self::PREFIX . $flag) ?: []
        );
    }

    private function invalidateCache(string $flag): void
    {
        Cache::forget('ff:' . $flag);
    }
}

Сервис-провайдер

<?php

namespace App\Providers;

use App\Services\FeatureFlags\FeatureFlagInterface;
use App\Services\FeatureFlags\RedisFeatureFlagService;
use Illuminate\Support\ServiceProvider;

class FeatureFlagServiceProvider extends ServiceProvider
{
    public function register(): void
    {
        $this->app->singleton(
            FeatureFlagInterface::class,
            RedisFeatureFlagService::class
        );
    }

    public function boot(): void
    {
        // Регистрируем Blade-директивы
        $ff = $this->app->make(FeatureFlagInterface::class);

        \Blade::if('feature', function (string $flag) use ($ff) {
            $userId = auth()->id();
            return $ff->isEnabled($flag, $userId);
        });
    }
}

Регистрируем провайдер в bootstrap/providers.php (Laravel 11+) или в config/app.php.

Middleware

Для защиты маршрутов за флагом:

<?php

namespace App\Http\Middleware;

use App\Services\FeatureFlags\FeatureFlagInterface;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;

class CheckFeatureFlag
{
    public function __construct(private FeatureFlagInterface $flags) {}

    public function handle(Request $request, Closure $next, string $flag): Response
    {
        if (!$this->flags->isEnabled($flag, auth()->id())) {
            abort(404);
        }

        return $next($request);
    }
}

Использование в маршрутах:

Route::get('/new-dashboard', NewDashboardController::class)
    ->middleware('feature:new_dashboard');

Blade-директивы

После регистрации провайдера в шаблонах доступно:

@feature('new_checkout')
    <x-new-checkout-form />
@else
    <x-legacy-checkout-form />
@endfeature

Хранение флагов в Redis

Структура данных

Мы используем Redis Hash для каждого флага. Это позволяет атомарно обновлять отдельные поля и читать всё одной командой HGETALL:

# Включить флаг полностью
REDIS-CLI HSET feature_flag:new_checkout status enabled

# Canary: включить для 10% пользователей
REDIS-CLI HSET feature_flag:new_checkout status canary percentage 10

# Whitelist: только для конкретных userId
REDIS-CLI HSET feature_flag:new_checkout status whitelist users '[1,2,42,100]'

# Посмотреть состояние флага
REDIS-CLI HGETALL feature_flag:new_checkout

TTL и автоматическое отключение

Для временных экспериментов устанавливайте TTL прямо на ключ:

# Флаг активен 7 дней (604800 секунд)
REDIS-CLI EXPIRE feature_flag:ab_test_header 604800

В Laravel-сервисе можно добавить метод:

public function enableWithTtl(string $flag, int $ttlSeconds): void
{
    Redis::hset(self::PREFIX . $flag, 'status', 'enabled');
    Redis::expire(self::PREFIX . $flag, $ttlSeconds);
    $this->invalidateCache($flag);
}

Горячее обновление без деплоя

Вся мощь подхода — возможность изменить поведение системы в секунды. Достаточно выполнить одну команду в Redis, и через CACHE_TTL (30 секунд в нашем примере) все инстансы приложения подхватят изменение. Никакого деплоя, никакого даунтайма.

Интеграция с CI/CD

Автоматическое включение флагов через GitHub Actions

Типичный сценарий: фича готова, деплой прошёл успешно — GitHub Actions активирует флаг через Redis API. Вот пример воркфлоу:

name: Deploy & Activate Feature Flags

on:
  push:
    branches: [main]

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Run tests
        run: php artisan test --parallel

      - name: Deploy to production
        run: ./deploy.sh
        env:
          DEPLOY_KEY: ${{ secrets.DEPLOY_KEY }}

      - name: Activate feature flag via API
        if: success()
        run: |
          curl -X POST https://api.yourapp.com/internal/feature-flags/new_checkout/enable \
            -H "Authorization: Bearer ${{ secrets.INTERNAL_API_TOKEN }}" \
            -H "Content-Type: application/json"

      - name: Start canary rollout (10%)
        if: success()
        run: |
          curl -X POST https://api.yourapp.com/internal/feature-flags/new_checkout/rollout \
            -H "Authorization: Bearer ${{ secrets.INTERNAL_API_TOKEN }}" \
            -H "Content-Type: application/json" \
            -d '{"percentage": 10}'

Internal API для управления флагами

<?php

namespace App\Http\Controllers\Internal;

use App\Services\FeatureFlags\FeatureFlagInterface;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;

class FeatureFlagController
{
    public function __construct(private FeatureFlagInterface $flags) {}

    public function enable(string $flag): JsonResponse
    {
        $this->flags->enable($flag);
        return response()->json(['status' => 'enabled', 'flag' => $flag]);
    }

    public function disable(string $flag): JsonResponse
    {
        $this->flags->disable($flag);
        return response()->json(['status' => 'disabled', 'flag' => $flag]);
    }

    public function rollout(string $flag, Request $request): JsonResponse
    {
        $percentage = $request->integer('percentage');
        $this->flags->setRolloutPercentage($flag, $percentage);
        return response()->json([
            'status'     => 'canary',
            'flag'       => $flag,
            'percentage' => $percentage,
        ]);
    }
}

Маршруты защищаем токеном через middleware auth:sanctum или собственный InternalApiAuth.

Сценарии использования

A/B тестирование

Разделяем пользователей на группы по ID:

// Чётные userId видят вариант B
public function isAbVariantB(int $userId): bool
{
    return $this->flags->isEnabled('ab_new_onboarding', $userId)
        && ($userId % 2 === 0);
}

Флаг ab_new_onboarding в режиме canary с процентом 50 автоматически покроет половину аудитории.

Canary Release

Постепенный релиз для Laravel позволяет снизить риски. Алгоритм прост: после деплоя активируем флаг для 5%, смотрим на метрики 30 минут, затем 25%, потом 100%:

# Через GitHub Actions или вручную
curl -X POST .../rollout -d '{"percentage": 5}'
# Ждём 30 минут, проверяем Sentry и Grafana
curl -X POST .../rollout -d '{"percentage": 25}'
# Ещё 1 час
curl -X POST .../enable  # 100%

Kill Switch — аварийный откат

Самый ценный сценарий. Новая фича вызывает деградацию — одна команда в Redis восстанавливает прежнее поведение без деплоя:

redis-cli HSET feature_flag:new_payment_processor status disabled

Или через API:

curl -X POST https://api.yourapp.com/internal/feature-flags/new_payment_processor/disable \
  -H "Authorization: Bearer $TOKEN"

Через 30 секунд (время жизни кеша) все серверы вернутся к старому коду.

Мониторинг состояния флагов

Логирование событий

Расширим сервис логированием через Laravel Events:

public function enable(string $flag): void
{
    Redis::hset(self::PREFIX . $flag, 'status', 'enabled');
    $this->invalidateCache($flag);
    logger()->info('Feature flag enabled', [
        'flag'    => $flag,
        'user_id' => auth()->id(),
        'ip'      => request()->ip(),
    ]);
    event(new FeatureFlagChanged($flag, 'enabled'));
}

Метрики и алерты

Интегрируйте с Prometheus через spatie/laravel-prometheus или просто инкрементируйте счётчик в Redis:

public function isEnabled(string $flag, ?int $userId = null): bool
{
    $result = $this->resolveFlag($flag, $userId);

    // Счётчик проверок флага для Grafana
    Redis::incr('ff_check:' . $flag . ':' . ($result ? 'true' : 'false'));

    return $result;
}

Ключи ff_check:* можно экспортировать в Prometheus через redis_exporter и строить дашборды в Grafana. Алерт на резкий рост ошибок после включения флага — стандартная практика для canary release в Laravel.

Сравнение с готовыми решениями

LaunchDarkly

LaunchDarkly — лидер рынка: богатый UI, таргетинг по атрибутам, аудиты, SDK для 30+ языков. Минусы: цена от $10 за пользователя/месяц, внешняя зависимость в критическом пути, данные уходят за пределы инфраструктуры.

Unleash

Open-source альтернатива с self-hosted вариантом. Есть официальный PHP SDK, поддержка стратегий, webhook-и. Требует отдельного сервера и поддержки. Хорошо подходит для средних и крупных команд.

Самописное решение на Laravel + Redis

Оптимально, когда:

  • Команда небольшая (до 10 разработчиков).
  • Требования к флагам стандартные: on/off, canary, whitelist.
  • Важна минимальная латентность (Redis в той же сети — микросекунды).
  • Нет желания платить за SaaS или поддерживать отдельный сервис.

Используйте LaunchDarkly или Unleash, если нужны: сложный таргетинг по произвольным атрибутам, аудит всех изменений, роль-based доступ к флагам или у вас 50+ флагов в активном использовании.

Лучшие практики и типичные ошибки

Лучшие практики

  • Называйте флаги описательно: new_checkout_v2 лучше, чем flag_42.
  • Удаляйте флаги после раскатки: технический долг — флаги, которые уже включены для 100% пользователей, но не убраны из кода. Ставьте напоминание в тикет-трекере.
  • Тестируйте оба пути: пишите тесты для включённого и выключенного состояния флага.
  • Документируйте флаги: храните описание, дату создания и владельца прямо в Redis Hash — поле description.
  • Используйте короткий TTL кеша (30–60 секунд) для быстрого применения изменений.

Типичные ошибки

  • Проверка флага на каждый запрос без кеширования — Redis быстрый, но лишние round-trip нагружают сеть. Всегда используйте application-level кеш.
  • Флаги в базе данных без репликации — при росте трафика MySQL/PostgreSQL становятся узким местом для feature flags.
  • Отсутствие дефолтного значения — если ключ не существует в Redis, сервис должен вернуть false, а не бросить исключение.
  • Слишком много вложенных флагов — логика вида «если флаг A и флаг B, но не флаг C» — запах плохой архитектуры. Упрощайте.
  • Забытые kill switch — keep switch должны быть всегда доступны команде, не только DevOps. Добавьте простой UI в Laravel Nova или Filament.

Заключение

Feature flags — это не просто инструмент деплоя. Это философия разработки, при которой код в продакшене всегда стабилен, а риски контролируются на уровне конфигурации, а не кода. Связка Laravel + Redis + CI/CD позволяет реализовать полноценную систему управления релизами за несколько часов работы.

Что вы получаете в итоге:

  • Постепенный релиз (canary) без инфраструктурных сложностей Kubernetes Canary Deployment.
  • Мгновенный откат через kill switch без деплоя.
  • A/B тестирование прямо в коде без сторонних сервисов.
  • Полный контроль данных и инфраструктуры.

Начните с одного флага для следующей рискованной фичи — и этот подход станет стандартом для всей команды. В 2026 году trunk-based development и feature flags — это не тренд, а необходимость для любой серьёзной PHP-команды.

Технологии

Теги

Руслан Исмаилов

Senior Web / Backend разработчик. Senior web/backend разработчик с 9-летним опытом. Стек: PHP, Laravel, PostgreSQL, Redis, Docker, Kubernetes, REST, микросервисы, CI/CD. Подробнее обо мне →