Backend development

Feature Flags in Production: Release Management with Laravel, Redis, and CI/CD Without Downtime

Ruslan Ismailov Published 14 min read
F

Introduction: Feature Flags in 2026

Feature flags (or feature toggles) are a mechanism that allows you to enable or disable application functionality without changing code or redeploying. In 2026, this practice has become the standard for teams using trunk-based development: all developers work in a single branch, and unfinished or risky code is hidden behind flags.

The connection to CI/CD is clear: the more frequently you deploy, the higher the risk of breaking production. Feature flags resolve this tension — code reaches production but remains inactive until explicitly enabled. This enables gradual releases, safe rollbacks, and flexible A/B testing without database operations or code changes.

In this article, we'll build a full-featured flag management system using Laravel and Redis, integrate it with GitHub Actions, and walk through real-world usage scenarios.

Solution Architecture

Our system consists of three layers:

  • Laravel — business logic, service provider, middleware, Blade directives.
  • Redis — fast in-memory flag storage with TTL support and hot updates without redeployment.
  • CI/CD (GitHub Actions) — automatic flag activation after a successful deployment.

The principle is straightforward: a flag is a Redis key with a value describing its state (enabled/disabled, user percentage, group list). Laravel reads this key on each request (with application-level caching) and decides whether to show the feature. The CI/CD pipeline manages flags via Redis CLI or an HTTP API after tests pass successfully.

Implementing the Feature Flag Service in Laravel

Contract and Implementation

We start with an interface to make the service easily testable and replaceable:

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

Now the Redis-based implementation:

<?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; // seconds

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

        // Whitelist: enabled only for specific users
        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
    {
        // Register Blade directives
        $ff = $this->app->make(FeatureFlagInterface::class);

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

Register the provider in bootstrap/providers.php (Laravel 11+) or in config/app.php.

Middleware

To protect routes behind a 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);
    }
}

Usage in routes:

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

Blade Directives

After registering the provider, the following is available in templates:

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

Storing Flags in Redis

Data Structure

We use a Redis Hash for each flag. This allows atomically updating individual fields and reading everything in a single HGETALL command:

# Enable the flag completely
REDIS-CLI HSET feature_flag:new_checkout status enabled

# Canary: enable for 10% of users
REDIS-CLI HSET feature_flag:new_checkout status canary percentage 10

# Whitelist: only for specific userIds
REDIS-CLI HSET feature_flag:new_checkout status whitelist users '[1,2,42,100]'

# Check the flag state
REDIS-CLI HGETALL feature_flag:new_checkout

TTL and Automatic Expiration

For temporary experiments, set a TTL directly on the key:

# Flag is active for 7 days (604800 seconds)
REDIS-CLI EXPIRE feature_flag:ab_test_header 604800

You can add a method to the Laravel service:

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

Hot Updates Without Redeployment

The real power of this approach is the ability to change system behavior in seconds. A single Redis command is enough, and within CACHE_TTL (30 seconds in our example) all application instances will pick up the change. No deployment, no downtime.

CI/CD Integration

Automatic Flag Activation via GitHub Actions

A typical scenario: the feature is ready, deployment succeeded — GitHub Actions activates the flag via the Redis API. Here's an example 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}'

Internal API for Flag Management

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

Routes are protected with a token via the auth:sanctum middleware or a custom InternalApiAuth middleware.

Usage Scenarios

A/B Testing

Split users into groups by ID:

// Even userIds see variant B
public function isAbVariantB(int $userId): bool
{
    return $this->flags->isEnabled('ab_new_onboarding', $userId)
        && ($userId % 2 === 0);
}

The ab_new_onboarding flag in canary mode with 50% will automatically cover half the audience.

Canary Release

A gradual rollout for Laravel reduces risk. The algorithm is simple: after deployment, activate the flag for 5%, monitor metrics for 30 minutes, then go to 25%, then 100%:

# Via GitHub Actions or manually
curl -X POST .../rollout -d '{"percentage": 5}'
# Wait 30 minutes, check Sentry and Grafana
curl -X POST .../rollout -d '{"percentage": 25}'
# Wait another hour
curl -X POST .../enable  # 100%

Kill Switch — Emergency Rollback

The most valuable scenario. A new feature causes degradation — one Redis command restores the previous behavior without a deployment:

redis-cli HSET feature_flag:new_payment_processor status disabled

Or via API:

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

Within 30 seconds (the cache TTL), all servers will revert to the old code.

Monitoring Flag State

Event Logging

Let's extend the service with logging via 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'));
}

Metrics and Alerts

Integrate with Prometheus via spatie/laravel-prometheus or simply increment a counter in Redis:

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

    // Flag check counter for Grafana
    Redis::incr('ff_check:' . $flag . ':' . ($result ? 'true' : 'false'));

    return $result;
}

The ff_check:* keys can be exported to Prometheus via redis_exporter and used to build dashboards in Grafana. An alert on a sharp spike in errors after enabling a flag is standard practice for canary releases in Laravel.

Comparison with Ready-Made Solutions

LaunchDarkly

LaunchDarkly is the market leader: a rich UI, attribute-based targeting, audit logs, SDKs for 30+ languages. Downsides: pricing from $10 per user/month, an external dependency in a critical path, and data leaving your infrastructure.

Unleash

An open-source alternative with a self-hosted option. It has an official PHP SDK, strategy support, and webhooks. It requires a dedicated server and ongoing maintenance. Works well for mid-size and large teams.

Custom Solution with Laravel + Redis

This is the optimal choice when:

  • The team is small (up to 10 developers).
  • Flag requirements are standard: on/off, canary, whitelist.
  • Minimal latency is important (Redis on the same network means microseconds).
  • You don't want to pay for SaaS or maintain a separate service.

Use LaunchDarkly or Unleash if you need: complex targeting by arbitrary attributes, a full audit trail of all changes, role-based access to flags, or if you have 50+ flags in active use.

Best Practices and Common Mistakes

Best Practices

  • Use descriptive flag names: new_checkout_v2 is better than flag_42.
  • Remove flags after full rollout: flags that are already enabled for 100% of users but haven't been removed from the code are technical debt. Set a reminder in your issue tracker.
  • Test both code paths: write tests for both the enabled and disabled states of a flag.
  • Document your flags: store the description, creation date, and owner directly in the Redis Hash — in a description field.
  • Use a short cache TTL (30–60 seconds) for fast propagation of changes.

Common Mistakes

  • Checking flags on every request without caching — Redis is fast, but extra round-trips put load on the network. Always use application-level caching.
  • Storing flags in the database without replication — under high traffic, MySQL/PostgreSQL become bottlenecks for feature flag reads.
  • Missing a default value — if a key doesn't exist in Redis, the service should return false, not throw an exception.
  • Too many nested flags — logic like "if flag A and flag B but not flag C" is a sign of poor architecture. Simplify.
  • Forgotten kill switches — kill switches must always be accessible to the whole team, not just DevOps. Add a simple UI in Laravel Nova or Filament.

Conclusion

Feature flags are more than just a deployment tool. They represent a development philosophy where production code is always stable and risks are managed at the configuration level, not in the code itself. The Laravel + Redis + CI/CD stack lets you implement a full-featured release management system in just a few hours.

Here's what you get as a result:

  • Gradual rollout (canary) without the infrastructure complexity of Kubernetes Canary Deployments.
  • Instant rollback via kill switch without redeployment.
  • A/B testing directly in the code without third-party services.
  • Full control over your data and infrastructure.

Start with a single flag for your next risky feature — and this approach will become the standard for your entire team. In 2026, trunk-based development and feature flags are not a trend; they are a necessity for any serious PHP team.

Technologies

Tags

Ruslan Ismailov

Senior Web / Backend Developer. Senior web/backend developer with 9 years of experience. Stack: PHP, Laravel, PostgreSQL, Redis, Docker, Kubernetes, REST, microservices, CI/CD. More about me →