Feature Flags in Production: Release Management with Laravel, Redis, and CI/CD Without Downtime
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_v2is better thanflag_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
descriptionfield. - 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 →