Feature Flags for SaaS: Progressive Delivery Without the Rollback
Feature flags for SaaS are one of those patterns that every engineering team eventually discovers — usually right after their first painful late-night rollback. The idea is seductive: deploy code whenever you want, then activate it only when you are ready. No more tying releases to sprint deadlines. No more "merge freeze" emails the day before a big customer demo.
But the pattern comes with a cost that rarely gets talked about in the tutorials. Within eighteen months of adopting flags carelessly, most teams accumulate what engineers call a "flag graveyard" — dozens of dead boolean checks scattered through the codebase, each one a small cognitive tax on every developer who reads that file. The problem is not feature flags themselves. The problem is treating them as a deployment shortcut rather than a structured engineering practice.
This guide covers how to do them properly: the taxonomy of flag types that actually matters, how to design a lifecycle that forces cleanup, and how to implement progressive delivery in Symfony and Next.js without subscribing to LaunchDarkly's enterprise pricing.
Why Most Teams Get Feature Flags Wrong
The failure pattern is consistent across codebases I have audited over the years. A team starts with one flag — maybe for a risky database migration or a new checkout flow. It works well, so they add more. After six months, nobody remembers which flags are still live, which are permanent configuration settings in disguise, and which were meant to expire two quarters ago.
The root cause is a naming and categorisation problem. When every flag is just isNewDashboardEnabled or featureX, the flag store becomes opaque. You cannot answer the question "what is this flag actually for?" without reading the code that uses it. That ambiguity is what kills codebases — not the flags themselves.
The fix is to choose a flag type before you create the flag, and encode that type in how you manage the flag's lifecycle.
The Three Flag Types That Actually Matter
Most feature flag taxonomies are overcomplicated. For the typical SaaS team, three categories cover almost every real use case.
Release flags are temporary. They wrap code that is not yet ready for all users — a new billing flow, a redesigned settings page, a performance experiment. Release flags should have a defined expiry: either a date, or a condition like "remove after 90 days in production." When the rollout completes and the old code path is gone, the flag must be deleted. If you cannot commit to deleting it, you should not create it.
Kill switches are permanent operational controls. They let you disable a specific integration or feature path without deploying code — useful for third-party services that have downtime, for features that turn out to generate unexpected load, or for regulatory reasons that require you to turn something off fast. Kill switches live indefinitely in the flag store, but they are always expected to be "on." When a kill switch is "off," something is wrong and the team should know about it.
Entitlement flags control what different customers or plans can access. They are not really feature flags in the classical sense — they are product configuration. If your SaaS has a free tier and a paid tier, the difference between what each tier can do belongs in entitlement flags. These are permanent and owned by the product team, not the engineering team.
This distinction matters practically: release flags should be reviewed and deleted on a regular schedule, kill switches should be monitored for unexpected off-states, and entitlement flags should live in your billing or plan configuration, not in a generic flag store.
Designing a Flag Lifecycle
A flag without an owner and an expiry date is a liability. Here is the minimal lifecycle that prevents graveyard accumulation.
When a flag is created, three things must be recorded: the flag type, the owner (the engineer or team responsible for cleaning it up), and for release flags, a target removal date. This can live in comments, in a database column, or in a YAML definition file — what matters is that it is written down and reviewed.
Every sprint, release flags past their target date should appear on someone's radar. A simple CI check that fails the build when a flag is past its expiry date sounds harsh, but it is remarkably effective. The pain of updating a date is much lower than the pain of accidentally shipping code that depends on a dead flag.
When a release flag is retired, the process is: enable it globally, wait one full deployment cycle to confirm stability, then delete the flag and all the old code paths it was protecting. Do not leave the flag in a "permanently enabled" state — that is just a different kind of graveyard.
Implementing Feature Flags in Symfony
For a Symfony application, the lightest approach that actually works in production does not require a third-party service. A database-backed flag store with a thin service layer handles the vast majority of SaaS needs.
Start with a FeatureFlag entity with columns for name (string), enabled (boolean), flag_type (enum: release, kill_switch, entitlement), owner (string), expires_at (nullable datetime), and context (JSON for entitlement rules). Add a unique index on name.
The service class is straightforward:
// src/Service/FeatureFlagService.php
final class FeatureFlagService
{
public function __construct(
private readonly FeatureFlagRepository $repository,
private readonly CacheInterface $cache,
) {}
public function isEnabled(string $name, array $context = []): bool
{
$flag = $this->cache->get(
'feature_flag_' . $name,
fn() => $this->repository->findOneByName($name)
);
if (null === $flag) {
return false; // fail closed — unknown flags are off
}
if (!$flag->isEnabled()) {
return false;
}
// Entitlement flags may carry context rules
if ($flag->getFlagType() === FlagType::Entitlement && !empty($flag->getContext())) {
return $this->evaluateEntitlementContext($flag->getContext(), $context);
}
return true;
}
}
A few implementation decisions worth making explicit. The service fails closed — if a flag does not exist in the store, isEnabled returns false. This prevents silent activation of new code when a flag accidentally goes missing. Results are cached per flag name; the cache TTL should be short (30–60 seconds) for kill switches and longer (5–10 minutes) for stable entitlement flags. Clearing the cache on flag updates is straightforward with Symfony's cache tagging.
For Twig templates, a simple extension makes flag checks readable:
{% if feature('new_billing_flow') %}
{% include 'billing/_new_flow.html.twig' %}
{% else %}
{% include 'billing/_legacy_flow.html.twig' %}
{% endif %}
For admin management of flags, EasyAdmin or SonataAdmin both handle a simple CRUD interface with minimal configuration. Expose it behind your existing admin firewall and you have a feature flag dashboard without building a custom UI.
Implementing Feature Flags in Next.js
On the frontend, the architectural choice is where to evaluate flags: at the edge, on the server, or on the client. Each has trade-offs.
Edge evaluation (via Next.js Middleware) is ideal for kill switches and high-stakes release flags where you cannot afford a flash of old content. Middleware runs before the page renders, so you can redirect or rewrite the request before the user sees anything. The constraint is that Middleware has no access to a database, so flags must come from an edge-compatible source — a KV store like Vercel KV, Cloudflare KV, or a Redis instance with a fast read path.
Server Component evaluation works well for most release flags and entitlements. Fetch the flag state in the Server Component, pass it down as a prop. No client-side JavaScript overhead, no layout shift, no flash of wrong content. This is the right default for new flags.
// app/billing/page.tsx
import { getFeatureFlag } from '@/lib/flags';
export default async function BillingPage() {
const newBillingFlow = await getFeatureFlag('new_billing_flow');
return newBillingFlow
? <NewBillingFlow />
: <LegacyBillingFlow />;
}
Client-side evaluation via a React context makes sense only for flags that change during a session — for example, an entitlement flag that updates when a user upgrades their plan without a full page reload. For everything else, server-side evaluation is simpler and more reliable.
The flag fetch function can hit your own API endpoint that proxies to the same Symfony backend — keeping a single source of truth rather than managing two separate flag stores.
// lib/flags.ts
export async function getFeatureFlag(name: string): Promise<boolean> {
const res = await fetch(`${process.env.API_BASE_URL}/feature-flags/${name}`, {
next: { revalidate: 60 }, // ISR-style cache
});
if (!res.ok) return false; // fail closed
const data = await res.json();
return data.enabled ?? false;
}
Progressive Rollout Without LaunchDarkly
LaunchDarkly is genuinely excellent software, but its enterprise pricing — often $1,000–$3,000 per month for a small team — is hard to justify when most of its value comes from percentage rollouts and targeting rules that you can implement yourself in a few hours.
For percentage-based rollouts, a deterministic hash of the user ID against the flag name gives consistent assignment without storing per-user flag state:
public function isEnabledForUser(string $flagName, string $userId): bool
{
$flag = $this->repository->findOneByName($flagName);
if (null === $flag || !$flag->isEnabled()) {
return false;
}
$rolloutPercentage = $flag->getRolloutPercentage() ?? 100;
if ($rolloutPercentage >= 100) {
return true;
}
// Deterministic: same user always gets same result for same flag
$hash = crc32($flagName . ':' . $userId) % 100;
return $hash < $rolloutPercentage;
}
Add a rollout_percentage integer column (0–100, nullable, default null meaning 100%) to the FeatureFlag entity and you have percentage rollouts. To gradually increase exposure, update the column value in your admin panel — no redeployment needed.
For targeting rules beyond percentage (by organisation, plan, or geographic region), a JSON context column on the flag stores the targeting logic, and the service evaluates it against a context array passed at call time. This covers the vast majority of what teams actually need from a commercial flag service.
Open-source alternatives worth evaluating if you want a dedicated service rather than a custom implementation: Unleash (self-hostable, strong Symfony SDK), Flipt (single binary, gRPC and REST), and Flagsmith (generous free tier for hosted, self-hostable for enterprise). Any of these runs comfortably on a small VPS alongside a SaaS stack.
Connecting Feature Flags to Your Deployment Pipeline
The last piece that makes progressive delivery feel seamless is closing the loop between CI/CD and the flag store. Two integrations make the biggest difference.
First, run a flag audit step in your CI pipeline. A simple script reads all isEnabled(...) calls in the codebase, cross-references them against the flag store, and fails the build if any flag in the code does not exist in the store (a typo risk) or if any flag in the store has not been referenced in code for more than 90 days (a graveyard candidate).
Second, wire up deployment events. When a deployment succeeds, automatically flip the target release flag to enabled for an internal user segment (e.g., users with a @yourcompany.com email). After 24 hours without errors, a scheduled task bumps the rollout percentage to 10%, then 50%, then 100% on a configurable cadence. This is proper progressive delivery — the kind that makes rollbacks a last resort rather than a weekly occurrence.
A Note on Monitoring
Feature flags are only as useful as your observability around them. Before enabling a release flag beyond internal users, make sure your error tracking (Sentry, Bugsnag) can filter by flag state, and that your key metrics (error rate, p95 response time, conversion rate) are being logged per flag cohort. Without this, you cannot tell whether a spike after a rollout is caused by the new code or by an unrelated deployment.
This is the discipline gap between teams that successfully use feature flags and teams that use them for a year and then rip them out in frustration.
Getting This Right the First Time
Feature flags are infrastructure, not a plugin you add when you get big enough. A clean implementation from the start — typed flags, explicit lifecycle, ownership, percentage rollout support — costs maybe two days of engineering time and pays for itself the first time you catch a production issue before it reaches all your users.
If your codebase already has flags scattered without structure, a systematic audit can turn them into an asset rather than a liability. It involves cataloguing every flag, classifying by type, assigning ownership, and building the tooling to prevent future graveyard accumulation.
If you are building this out and want a second opinion on the architecture — or if you have inherited a flag graveyard and want help cleaning it up — get in touch at hello@wolf-tech.io or take a look at the code quality consulting service at wolf-tech.io. Structural problems like this are exactly what that service is designed to address.
FAQ
Do I need a feature flag service like LaunchDarkly to use feature flags effectively?
No. For most SaaS teams, a database-backed implementation with percentage rollout support covers the full feature set. Commercial services add value at scale or when you need real-time analytics and A/B testing integrations, but they are not required to start.
What is the difference between a feature flag and an environment variable?
Environment variables are set at deploy time and require redeployment to change. Feature flags can be toggled at runtime without touching the deployment pipeline. Use environment variables for infrastructure configuration; use feature flags for code behaviour you may need to change while the system is running.
How do I prevent flag graveyard accumulation?
Assign every release flag a target removal date at creation time. Run a CI check that alerts or fails when flags exceed their expiry. Make flag cleanup a first-class engineering task — not something that gets deferred to "next quarter."
Can feature flags slow down my application?
With caching, the overhead is negligible — a single cache lookup per request. Without caching, database-backed flags can add latency if checked frequently in hot paths. Keep flag checks out of tight loops, cache aggressively, and monitor the query count if you add flags to high-traffic endpoints.

