Desarrollo backend

Feature Flags en producción: gestión de releases con Laravel, Redis y CI/CD sin downtime

Ruslan Ismailov Publicado 14 min de lectura
F

Introducción: Feature Flags en 2026

Los feature flags (o feature toggles) son un mecanismo que permite activar y desactivar funcionalidades de una aplicación sin modificar el código ni realizar un nuevo despliegue. En 2026, esta práctica se ha convertido en el estándar para equipos que utilizan trunk-based development: todos los desarrolladores trabajan en una sola rama, y el código incompleto o arriesgado se oculta detrás de flags.

La relación con CI/CD es evidente: cuanto más frecuente es el despliegue, mayor es el riesgo de romper producción. Los feature flags rompen esta contradicción: el código llega a producción pero permanece inactivo hasta su activación explícita. Esto permite un lanzamiento gradual, un rollback seguro y pruebas A/B flexibles sin operaciones sobre la base de datos ni el código fuente.

En este artículo construiremos un sistema completo de gestión de flags con Laravel y Redis, lo integraremos con GitHub Actions y analizaremos escenarios de uso reales.

Arquitectura de la solución

Nuestro sistema consta de tres capas:

  • Laravel — lógica de negocio, service provider, middleware y directivas Blade.
  • Redis — almacenamiento in-memory rápido de flags con soporte de TTL y actualización en caliente sin despliegue.
  • CI/CD (GitHub Actions) — activación automática de flags tras un despliegue exitoso.

El principio de funcionamiento es simple: un flag es una clave en Redis con un valor que describe su estado (activado/desactivado, porcentaje de usuarios, lista de grupos). Laravel lee esa clave en cada petición (con caché a nivel de aplicación) y decide si muestra la funcionalidad. El pipeline de CI/CD gestiona los flags a través de Redis CLI o una HTTP API tras superar los tests correctamente.

Implementación del servicio Feature Flag en Laravel

Contrato e implementación

Comenzamos con una interfaz para que el servicio sea fácilmente testeable y reemplazable:

<?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;
}

Ahora la implementación basada en 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; // segundos

    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: activación porcentual por userId
        if ($data['status'] === 'canary' && $userId !== null) {
            $percentage = (int) ($data['percentage'] ?? 0);
            return ($userId % 100) < $percentage;
        }

        // Whitelist: activado solo para usuarios específicos
        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);
    }
}

Service Provider

<?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
    {
        // Registramos las directivas Blade
        $ff = $this->app->make(FeatureFlagInterface::class);

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

Registramos el provider en bootstrap/providers.php (Laravel 11+) o en config/app.php.

Middleware

Para proteger rutas detrás de un flag:

<?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);
    }
}

Uso en las rutas:

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

Directivas Blade

Tras registrar el provider, en las plantillas está disponible:

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

Almacenamiento de flags en Redis

Estructura de datos

Usamos un Redis Hash para cada flag. Esto permite actualizar campos de forma atómica y leerlo todo con un solo comando HGETALL:

# Activar el flag completamente
REDIS-CLI HSET feature_flag:new_checkout status enabled

# Canary: activar para el 10% de usuarios
REDIS-CLI HSET feature_flag:new_checkout status canary percentage 10

# Whitelist: solo para userId específicos
REDIS-CLI HSET feature_flag:new_checkout status whitelist users '[1,2,42,100]'

# Ver el estado del flag
REDIS-CLI HGETALL feature_flag:new_checkout

TTL y desactivación automática

Para experimentos temporales, establece el TTL directamente en la clave:

# Flag activo durante 7 días (604800 segundos)
REDIS-CLI EXPIRE feature_flag:ab_test_header 604800

En el servicio de Laravel puedes añadir el método:

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

Actualización en caliente sin despliegue

Toda la potencia del enfoque reside en poder cambiar el comportamiento del sistema en segundos. Basta con ejecutar un comando en Redis y, tras CACHE_TTL (30 segundos en nuestro ejemplo), todas las instancias de la aplicación aplicarán el cambio. Sin despliegue, sin downtime.

Integración con CI/CD

Activación automática de flags con GitHub Actions

Escenario típico: la funcionalidad está lista, el despliegue fue exitoso — GitHub Actions activa el flag a través de la Redis API. Aquí un ejemplo de workflow:

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}'

API interna para la gestión de flags

<?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,
        ]);
    }
}

Las rutas se protegen con un token mediante el middleware auth:sanctum o un InternalApiAuth personalizado.

Escenarios de uso

Pruebas A/B

Dividimos los usuarios en grupos por ID:

// Los userId pares ven la variante B
public function isAbVariantB(int $userId): bool
{
    return $this->flags->isEnabled('ab_new_onboarding', $userId)
        && ($userId % 2 === 0);
}

El flag ab_new_onboarding en modo canary con un porcentaje del 50 cubrirá automáticamente a la mitad de la audiencia.

Canary Release

El lanzamiento gradual en Laravel permite reducir riesgos. El algoritmo es simple: tras el despliegue, activamos el flag para el 5%, observamos las métricas durante 30 minutos, luego al 25% y finalmente al 100%:

# Mediante GitHub Actions o manualmente
curl -X POST .../rollout -d '{"percentage": 5}'
# Esperamos 30 minutos, revisamos Sentry y Grafana
curl -X POST .../rollout -d '{"percentage": 25}'
# Una hora más
curl -X POST .../enable  # 100%

Kill Switch — rollback de emergencia

El escenario más valioso. Una nueva funcionalidad provoca degradación — un solo comando en Redis restaura el comportamiento anterior sin despliegue:

redis-cli HSET feature_flag:new_payment_processor status disabled

O a través de la API:

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

En 30 segundos (tiempo de vida del caché), todos los servidores volverán al código anterior.

Monitoreo del estado de los flags

Registro de eventos

Extendemos el servicio con logging a través de 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'));
}

Métricas y alertas

Integra con Prometheus mediante spatie/laravel-prometheus o simplemente incrementa un contador en Redis:

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

    // Contador de verificaciones del flag para Grafana
    Redis::incr('ff_check:' . $flag . ':' . ($result ? 'true' : 'false'));

    return $result;
}

Las claves ff_check:* pueden exportarse a Prometheus a través de redis_exporter y construir dashboards en Grafana. Una alerta ante un aumento brusco de errores tras la activación de un flag es una práctica estándar en los canary releases de Laravel.

Comparación con soluciones existentes

LaunchDarkly

LaunchDarkly es el líder del mercado: UI rica, segmentación por atributos, auditorías, SDK para más de 30 lenguajes. Desventajas: precio desde $10 por usuario/mes, dependencia externa en la ruta crítica y los datos salen de tu infraestructura.

Unleash

Alternativa open-source con opción self-hosted. Cuenta con SDK oficial para PHP, soporte de estrategias y webhooks. Requiere un servidor propio y mantenimiento. Adecuado para equipos medianos y grandes.

Solución propia con Laravel + Redis

Es la opción óptima cuando:

  • El equipo es pequeño (hasta 10 desarrolladores).
  • Los requisitos de los flags son estándar: on/off, canary, whitelist.
  • La latencia mínima es prioritaria (Redis en la misma red — microsegundos).
  • No se quiere pagar por SaaS ni mantener un servicio separado.

Usa LaunchDarkly o Unleash si necesitas: segmentación compleja por atributos arbitrarios, auditoría de todos los cambios, acceso basado en roles a los flags o tienes más de 50 flags en uso activo.

Buenas prácticas y errores comunes

Buenas prácticas

  • Nombra los flags de forma descriptiva: new_checkout_v2 es mejor que flag_42.
  • Elimina los flags tras el despliegue completo: la deuda técnica son los flags que ya están activos para el 100% de los usuarios pero no se han eliminado del código. Añade un recordatorio en el ticket tracker.
  • Prueba ambas rutas: escribe tests para el estado activado y desactivado del flag.
  • Documenta los flags: guarda la descripción, fecha de creación y propietario directamente en el Redis Hash — campo description.
  • Usa un TTL de caché corto (30–60 segundos) para aplicar los cambios rápidamente.

Errores comunes

  • Verificar el flag en cada petición sin caché — Redis es rápido, pero los round-trips innecesarios sobrecargan la red. Usa siempre caché a nivel de aplicación.
  • Flags en base de datos sin replicación — con el crecimiento del tráfico, MySQL/PostgreSQL se convierten en un cuello de botella para los feature flags.
  • Ausencia de valor por defecto — si la clave no existe en Redis, el servicio debe devolver false y no lanzar una excepción.
  • Demasiados flags anidados — lógica del tipo «si flag A y flag B, pero no flag C» es un indicio de mala arquitectura. Simplifica.
  • Kill switches olvidados — el kill switch debe estar siempre accesible para todo el equipo, no solo para DevOps. Añade una UI sencilla en Laravel Nova o Filament.

Conclusión

Los feature flags no son solo una herramienta de despliegue. Son una filosofía de desarrollo en la que el código en producción siempre es estable y los riesgos se controlan a nivel de configuración, no de código. La combinación Laravel + Redis + CI/CD permite implementar un sistema completo de gestión de releases en pocas horas de trabajo.

Lo que obtienes al final:

  • Lanzamiento gradual (canary) sin las complejidades de infraestructura de Kubernetes Canary Deployment.
  • Rollback instantáneo mediante kill switch sin despliegue.
  • Pruebas A/B directamente en el código sin servicios externos.
  • Control total de los datos y la infraestructura.

Empieza con un único flag para la próxima funcionalidad de riesgo — y este enfoque se convertirá en el estándar para todo el equipo. En 2026, el trunk-based development y los feature flags no son una tendencia, son una necesidad para cualquier equipo PHP serio.

Tecnologías

Etiquetas

Ruslan Ismailov

Desarrollador Senior Web / Backend. Desarrollador senior web/backend con 9 años de experiencia. Stack: PHP, Laravel, PostgreSQL, Redis, Docker, Kubernetes, REST, microservicios, CI/CD. Más sobre mí →