without third-party packages
Feature flags enable continuous deployment without feature branching: code is merged, but features stay disabled until they're ready. A homegrown solution built on Symfony's own building blocks is leaner, faster and fully under your control, with no external dependencies and no vendor lock-in.
Table of Contents
- 1. Why feature flags, and why build them yourself
- 2. The feature flag registry: central flag management
- 3. Configuring flags in symfony.yaml
- 4. A Twig extension for feature flag checks in templates
- 5. Voter integration: role-based feature flags
- 6. Dynamic flags from the database
- 7. Cache strategy for performant flag checks
- 8. A/B testing with percentage-based feature flags
- 9. A homegrown solution vs. feature flag libraries
- 10. Summary
- 11. FAQ
1. Why feature flags, and why build them yourself
Feature flags (also called feature toggles or feature switches) decouple deploying new code from releasing new features to users. A feature can be merged into the main branch, tested and deployed without being visible to all users. This enables trunk-based development without long-lived feature branches, reduces merge conflicts and turns releases into a business decision rather than a technical event. For internal beta tests, feature flags can be set so that only certain roles or user groups see new features.
Why not just use a ready-made package? Symfony projects have specific requirements: flags should be configurable in the familiar services.yaml structure, usable in Twig templates, and work together with Symfony's voter system for role-based flags. A homegrown solution typically runs to 200 to 400 lines of PHP code, manageable, fully understandable for the whole team, and without a dependency on a package that might not be maintained. The custom implementation can be tailored exactly to the requirements: static configuration flags for environments, database-driven flags for runtime control and percentage-based flags for A/B tests.
2. The feature flag registry: central flag management
The centerpiece of a homegrown feature flag implementation is the registry. It is a Symfony service that knows about all configured flags and offers a unified isEnabled(string $flag): bool method. The service is supplied with the flag array from configuration via constructor injection and registers various flag providers: static flags from configuration, database-driven flags for runtime control and user-dependent flags for role-based activation. The strategy pattern makes it easy to add new providers without changing the registry class, a new provider simply implements an interface and gets registered as a tagged Symfony service.
The registry caches flag results for the duration of a request. This prevents a page with ten feature flag checks from firing ten separate database queries. For flags that never change at runtime (static configuration flags), the result is cached permanently. For database-driven flags, a configurable TTL cache is used. This gives full control over caching behavior with no overhead from an external caching library, Symfony's own CacheInterface is perfectly sufficient.
<?php
declare(strict_types=1);
namespace App\FeatureFlag;
use Psr\Cache\CacheItemPoolInterface;
/**
* Central feature flag registry, single point of truth for all flag checks.
* Supports static config flags, database-driven flags and role-based flags.
*/
final class FeatureFlagRegistry
{
/** @var array<string, bool|null> Runtime cache for this request */
private array $cache = [];
/**
* @param array<string, bool> $staticFlags Flags from config (always fast)
* @param iterable<FeatureFlagProviderInterface> $providers Additional providers (DB, role-based)
*/
public function __construct(
private readonly array $staticFlags,
private readonly iterable $providers,
private readonly CacheItemPoolInterface $cachePool,
) {}
/**
* Check if a feature flag is enabled.
* Checks static config first (fast path), then registered providers.
*/
public function isEnabled(string $flag, mixed $context = null): bool
{
// Fast path: check request-level cache first
$cacheKey = $flag . ($context ? '_' . md5(serialize($context)) : '');
if (isset($this->cache[$cacheKey])) {
return $this->cache[$cacheKey];
}
// Static config flags always override dynamic flags
if (array_key_exists($flag, $this->staticFlags)) {
return $this->cache[$cacheKey] = $this->staticFlags[$flag];
}
// Try each registered provider in priority order
foreach ($this->providers as $provider) {
if ($provider->supports($flag)) {
$result = $provider->isEnabled($flag, $context);
return $this->cache[$cacheKey] = $result;
}
}
// Default: disabled if not explicitly configured
return $this->cache[$cacheKey] = false;
}
/**
* Return all registered flags with their current state, useful for debug toolbar.
*
* @return array<string, bool>
*/
public function getAllFlags(mixed $context = null): array
{
$flags = [];
foreach (array_keys($this->staticFlags) as $flag) {
$flags[$flag] = $this->isEnabled($flag, $context);
}
return $flags;
}
}
3. Configuring flags in symfony.yaml
Static feature flags are configured as parameters in services.yaml or in a dedicated config/packages/feature_flags.yaml. This allows environment-dependent configuration through Symfony's environment system: in dev, all flags can be enabled, in staging only selected ones, and in prod only approved features. The configuration follows the same conventions as the rest of the Symfony configuration, no new concept, no bespoke configuration language.
The Symfony dependency injection container injects the flag array as a service parameter into the registry. This means the configuration is fully static at container build time and has zero runtime overhead. No database query, no Redis lookup for static flags, they are hard-wired as a PHP array in the compiled container. For flags that need to change without a cache clear, you need the dynamic providers from the next section. The clean separation between static (environment-based) and dynamic (runtime-controlled) feature flags is the key to a performant and maintainable system.
4. A Twig extension for feature flag checks in templates
In Twig templates, feature flags are made available through a dedicated Twig extension. The extension registers the feature_enabled() function and, optionally, its own {% feature 'flag' %}...{% endfeature %} tag that only renders a template block when the flag is active. The Twig extension receives the FeatureFlagRegistry as a dependency and delegates all checks to it. That means the registry's caching logic also applies to template checks, multiple feature_enabled('new_checkout') calls in one template don't trigger multiple checks.
The {% feature %} tag makes templates more readable than a nested {% if feature_enabled() %} chain. It communicates intent: this block is feature-flagged and can be disabled. That matters especially for code reviews and maintenance, once a feature has fully rolled out, a developer can specifically search for the tag and remove the stale code. Feature flag debt, meaning flags that were never removed, is one of the most common problems with feature flags in practice. A naming convention and regular audits of active flags help keep that technical debt under control.
<?php
declare(strict_types=1);
namespace App\FeatureFlag\Twig;
use App\FeatureFlag\FeatureFlagRegistry;
use Twig\Extension\AbstractExtension;
use Twig\TwigFunction;
/**
* Twig extension for feature flag checks in templates.
* Provides feature_enabled() function and optional flag context.
*/
final class FeatureFlagExtension extends AbstractExtension
{
public function __construct(
private readonly FeatureFlagRegistry $registry,
) {}
/** @return TwigFunction[] */
public function getFunctions(): array
{
return [
// Usage in Twig: {% if feature_enabled('new_checkout') %}
new TwigFunction('feature_enabled', $this->isFeatureEnabled(...)),
// Usage: {{ feature_list() }} for debug toolbar display
new TwigFunction('feature_list', $this->getFeatureList(...)),
];
}
/**
* Check if a feature flag is enabled, proxies to registry with request caching.
*/
public function isFeatureEnabled(string $flag, mixed $context = null): bool
{
return $this->registry->isEnabled($flag, $context);
}
/**
* Return all flags with state, useful for debug overlays in dev environment.
*
* @return array<string, bool>
*/
public function getFeatureList(): array
{
return $this->registry->getAllFlags();
}
}
// Usage in Twig template:
// {% if feature_enabled('new_checkout_flow') %}
// {{ include('checkout/new-flow.html.twig') }}
// {% else %}
// {{ include('checkout/legacy-flow.html.twig') }}
// {% endif %}
// services.yaml registration (auto-tagging via Twig bundle):
// App\FeatureFlag\Twig\FeatureFlagExtension:
// tags: ['twig.extension']
5. Voter integration: role-based feature flags
Role-based feature flags check not only whether a flag is enabled, but also whether the current user belongs to the group the flag applies to. The classic scenario: a new admin dashboard should only be visible to users with the ROLE_BETA role. The Symfony voter is the natural place for this check, it has access to the security context and can inspect roles, user attributes and any other property of the logged-in user. A feature flag provider that internally calls the voter ties the flag system into Symfony's security architecture.
The benefit of this integration: role-based feature flags follow exactly the same patterns as regular Symfony security checks. The team doesn't need to learn a separate concept for user-dependent flags. Changes to user roles automatically affect feature flag evaluation, no extra configuration effort. And the voter logic is testable like any other voter: with a VoterInterface test that simply calls the vote() method and checks the result, without building a full HTTP request.
6. Dynamic flags from the database
Static configuration flags are enough for many use cases, but sometimes feature flags need to change at runtime without a deployment. Database-driven flags solve this problem: a simple table with a flag name and status can be changed through an admin interface or directly via SQL, and the change takes effect after the next cache expiry. The database provider implements the same FeatureFlagProviderInterface as every other provider and is injected into the registry as a tagged service.
The database provider's implementation uses Doctrine DBAL or a simple Doctrine repository. A caching layer is essential: the provider doesn't hit the database on every flag check. Instead, all flags are loaded from the database in a single query on first access and cached for a configurable TTL. With a TTL of 60 seconds, the application reacts to flag changes within a minute without overwhelming the database with flag queries. Combined with an admin interface that explicitly invalidates the cache after saving, the reaction time can be reduced to seconds.
<?php
declare(strict_types=1);
namespace App\FeatureFlag\Provider;
use App\FeatureFlag\FeatureFlagProviderInterface;
use Doctrine\DBAL\Connection;
use Symfony\Contracts\Cache\CacheInterface;
use Symfony\Contracts\Cache\ItemInterface;
/**
* Database-driven feature flag provider with cache layer.
* Loads all flags from DB in a single query, caches with configurable TTL.
*/
final class DatabaseFeatureFlagProvider implements FeatureFlagProviderInterface
{
/** @var array<string, bool>|null Loaded flags from database */
private ?array $loadedFlags = null;
public function __construct(
private readonly Connection $connection,
private readonly CacheInterface $cache,
private readonly int $cacheTtl = 60,
) {}
public function supports(string $flag): bool
{
// This provider handles any flag that exists in the database
return array_key_exists($flag, $this->getFlags());
}
public function isEnabled(string $flag, mixed $context = null): bool
{
return $this->getFlags()[$flag] ?? false;
}
/**
* Load all flags from database, cached for $cacheTtl seconds.
*
* @return array<string, bool>
*/
private function getFlags(): array
{
if ($this->loadedFlags !== null) {
return $this->loadedFlags; // Request-level cache
}
$this->loadedFlags = $this->cache->get(
'feature_flags.all',
function (ItemInterface $item): array {
$item->expiresAfter($this->cacheTtl);
// Single query loads all flags, no N+1 for flag checks
$rows = $this->connection->fetchAllKeyValue(
'SELECT flag_name, is_enabled FROM feature_flags',
);
// Convert tinyint to bool
return array_map(fn($v) => (bool) $v, $rows);
},
);
return $this->loadedFlags;
}
/**
* Invalidate cache after admin changes a flag, call from admin controller.
*/
public function invalidateCache(): void
{
$this->cache->delete('feature_flags.all');
$this->loadedFlags = null;
}
}
7. Cache strategy for performant flag checks
The cache strategy for feature flags in Symfony has two levels: the request-level cache in the registry (a PHP array maintained for the duration of a request) and the persistent cache for database-driven flags (Redis or Memcached via Symfony's cache component). The request-level cache prevents duplicate work within a request: if a page contains twenty feature flag checks for the same flag, only the first one hits the provider. The persistent cache prevents database queries for flags that rarely change.
Tag-based cache invalidation is the cleanest strategy for how the two levels interact: all database-driven flags are cached with the feature_flags tag. When an admin interface changes a flag, the tag is invalidated and all cached flags become invalid at once. That's more precise than a simple TTL cache and reacts immediately to changes. Combined with the request-level cache, the result is: zero overhead for static flags (wired into the container), one database query per cache miss for dynamic flags, and no further query for the remaining requests within the TTL.
8. A/B testing with percentage-based feature flags
Percentage-based feature flags enable a feature only for a defined percentage of users, the basic tool for A/B testing and gradual feature rollouts. The implementation uses a deterministic hash function: the user ID is hashed together with the flag name, and the hash value modulo 100 is compared against the configured percentage. The result is stable: the same user always sees the feature, or never does, until the percentage is changed. Random activation per request would lead to inconsistent experiences, where a user jumps back and forth between old and new features.
The percentage-based provider in Symfony receives the current user from the security context. For unauthenticated users, a session ID can be used as the seed, ensuring consistent behavior even for anonymous users. The percentage is configured via a database flag with an additional rollout_percentage field. The admin interface makes it possible to gradually increase the percentage from 0 to 100 while the team observes the application's behavior and rolls back immediately if problems occur.
9. A homegrown solution vs. feature flag libraries
The PHP package ecosystem offers several feature flag libraries: unleash/client, flagsmith/flagsmith-php-client and various Symfony bundles. A direct comparison shows where a homegrown solution has the edge, and when a library makes more sense.
| Criterion | Homegrown Solution | External Library | SaaS Solution |
|---|---|---|---|
| Control | Full | Limited by API | Vendor lock-in |
| Symfony Integration | Native | Bundle required | HTTP overhead |
| Maintenance Effort | Self-responsible | Community | Vendor |
| A/B Testing UI | Build it yourself | Often minimal | Fully included |
| Cost | One-time development time | Mostly open source | Monthly fees |
The recommendation: for projects with clear, stable requirements around feature flags, a homegrown solution is the most pragmatic choice. 200 to 400 lines of PHP code, fully within the Symfony ecosystem, no vendor overhead. For projects that need advanced A/B testing with statistical evaluation, multivariate tests and a full admin dashboard, a SaaS solution like LaunchDarkly or Flagsmith is worth it, but only once those requirements actually exist, not built in as preemptive complexity.
Mironsoft
Symfony feature development, continuous deployment and release engineering
Need feature flags implemented in Symfony?
We build custom feature flag systems for Symfony projects, from the flag registry through Twig integration to dynamic database flags and percentage-based A/B test rollouts.
Flag System Design
Registry, provider architecture and cache strategy for performant feature flag checks
Admin Interface
Symfony EasyAdmin-based flag management with cache invalidation and audit log
A/B Testing
Percentage-based rollouts and user-based flag activation for gradual feature releases
10. Summary
Implementing feature flags in Symfony without third-party packages is feasible in 200 to 400 lines of PHP code and delivers a solution fully aligned with Symfony's architecture. The flag registry as a central service combines static configuration flags (zero runtime overhead, wired into the container) with dynamic database flags (cached with a configurable TTL). The Twig extension makes flags usable in templates without detours. Voter integration ties flags into Symfony's security architecture. Percentage-based flags enable gradual rollouts and A/B tests with deterministic user assignment.
The decisive advantage over external solutions is full control and integration: flag configuration in the same YAML files as the rest of the Symfony configuration, flag checks in Twig with familiar syntax, debugging in the Symfony profiler. The feature flag system stays transparent and maintainable for the whole team, no black-box package that might not keep up with the next Symfony major update.
Symfony Feature Flags, the essentials at a glance
Flag Registry
Central service with isEnabled(string $flag): bool. Request-level cache prevents duplicate work. Static and dynamic providers decoupled via interface.
Twig Integration
feature_enabled('flag') in Twig via extension. Same registry, same cache logic. Readable in templates, searchable for flag cleanup.
Dynamic Flags
Database provider loads all flags in a single query, cached via Symfony CacheInterface. Tag-based invalidation after admin changes.
A/B Testing
Deterministic hash from user ID plus flag name. The same user always sees the same feature. Increase percentage gradually from 0 to 100.