<?php
namespace App\Feature;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Contracts\Service\ResetInterface;
final class FeatureFlagManager implements ResetInterface
{
private array $cache = [];
public function __construct(
private readonly RequestStack $requestStack,
private readonly array $rollouts = [],
private readonly bool $allowHeaderOverride = false,
) {
}
public function isEnabled(string $flag): bool
{
return $this->cache[$flag] ??= $this->evaluate($flag);
}
private function evaluate(string $flag): bool
{
$request = $this->requestStack->getCurrentRequest();
if ($this->allowHeaderOverride && $request !== null) {
$header = $request->headers->get('X-Feature-' . str_replace('_', '-', $flag));
if ($header !== null) {
return filter_var($header, FILTER_VALIDATE_BOOL);
}
}
$percentage = $this->rollouts[$flag] ?? 0;
if ($percentage <= 0) {
return false;
}
if ($percentage >= 100) {
return true;
}
$identity = $request?->getSession()->getId() ?? $request?->getClientIp() ?? 'anon';
$bucket = crc32($flag . ':' . $identity) % 100;
return $bucket < $percentage;
}
public function reset(): void
{
$this->cache = [];
}
}
<?php
namespace App\Twig;
use App\Feature\FeatureFlagManager;
use Twig\Extension\AbstractExtension;
use Twig\TwigFunction;
final class FeatureExtension extends AbstractExtension
{
public function __construct(private readonly FeatureFlagManager $flags)
{
}
public function getFunctions(): array
{
return [
new TwigFunction('feature', $this->isEnabled(...)),
];
}
public function isEnabled(string $flag): bool
{
return $this->flags->isEnabled($flag);
}
}
<?php
namespace App\Feature;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpKernel\Event\ControllerEvent;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\HttpKernel\KernelEvents;
#[\Attribute(\Attribute::TARGET_METHOD | \Attribute::TARGET_CLASS)]
final class IsFeatureEnabled
{
public function __construct(public readonly string $flag)
{
}
}
final class FeatureGateSubscriber implements EventSubscriberInterface
{
public function __construct(private readonly FeatureFlagManager $flags)
{
}
public static function getSubscribedEvents(): array
{
return [KernelEvents::CONTROLLER => 'onController'];
}
public function onController(ControllerEvent $event): void
{
foreach ($event->getAttributes(IsFeatureEnabled::class) as $attribute) {
if (!$this->flags->isEnabled($attribute->flag)) {
throw new NotFoundHttpException(sprintf('Feature "%s" is disabled.', $attribute->flag));
}
}
}
}
<?php
namespace App\Controller;
use App\Feature\FeatureFlagManager;
use App\Feature\IsFeatureEnabled;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
#[Route('/checkout')]
final class CheckoutController extends AbstractController
{
#[Route('', name: 'checkout_start')]
#[IsFeatureEnabled('new_checkout')]
public function start(): Response
{
return $this->render('checkout/start.html.twig');
}
#[Route('/confirm', name: 'checkout_confirm', methods: ['POST'])]
public function confirm(FeatureFlagManager $flags): Response
{
$template = $flags->isEnabled('one_click_confirm')
? 'checkout/confirm_one_click.html.twig'
: 'checkout/confirm_classic.html.twig';
return $this->render($template);
}
}
This snippet shows how a small feature-flag system is wired into a Symfony application so that a flag is evaluated once per request and consumed consistently in services, templates, and controllers. The core idea is a request-scoped evaluator: flags often depend on the current user, request headers, or a percentage rollout, so their values should be stable for the lifetime of a single request but recomputed on the next one. Memoizing per request avoids both the cost of re-evaluating rules repeatedly and the correctness bugs that arise when a flag flips mid-request.
In FeatureFlagManager, the service holds an injected RequestStack and an in-memory $cache keyed by flag name. isEnabled() returns the memoized value if present, otherwise it delegates to evaluate() and stores the result. The evaluate() method demonstrates two common strategies: a header override (X-Feature-*) useful for QA and automated tests, and a deterministic percentage rollout computed with crc32 over the flag name plus a stable per-request identifier. Using a hash of a stable key rather than rand() means the same user consistently lands in or out of the rollout bucket, which is essential for a coherent experience and for A/B measurement.
Because the manager depends on RequestStack, it naturally behaves as request-scoped without needing custom container scopes; each request populates a fresh RequestStack frame, and the reset() method (invoked via kernel.reset) clears the memo cache between requests when the service container is reused, as with worker-based runtimes.
The FeatureExtension Twig extension exposes a single feature() function that forwards to the same manager, so templates can branch with {% if feature('new_checkout') %} and share exactly the evaluation logic used elsewhere. This keeps template logic thin and prevents drift between backend and view-layer decisions.
Finally, CheckoutController shows the controller gate. The #[IsFeatureEnabled('new_checkout')] attribute declares intent declaratively, while the inline isEnabled() check inside confirm() illustrates the imperative fallback for finer-grained branching. A pitfall worth noting: header overrides must be restricted to trusted environments, otherwise clients could toggle features at will. This pattern is worth reaching for whenever rollouts, kill switches, or per-user experiments need one source of truth across the stack.
Related snips
module Api
module V1
class UsersController < BaseController
def show
user = User.includes(:profile).find(params[:id])
ETags for conditional requests and caching
class SignupForm
include ActiveModel::Model
include ActiveModel::Attributes
attribute :account_name, :string
attribute :email, :string
Shallow Controller, Deep Params: Form Object Pattern
package com.example.myapp
import android.app.Application
import dagger.hilt.android.HiltAndroidApp
@HiltAndroidApp
Dependency injection with Hilt
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["form"]
static values = { delay: { type: Number, default: 250 } }
Debounced live search with Stimulus + Turbo Streams
json.array! @posts do |post|
json.cache! ['v1', post], expires_in: 1.hour do
json.id post.id
json.title post.title
json.excerpt post.excerpt
json.published_at post.published_at
Fragment caching for expensive JSON serialization
<?php
namespace App\Providers;
use App\Contracts\PaymentGateway;
use App\Services\StripePaymentGateway;
Laravel service container and dependency injection
Share this code
Here's the card — post it anywhere.