Feature flags control feature rollouts without code deployments. They enable progressive delivery, A/B testing, and instant kill switches. But without discipline, flags become technical debt that clutters your codebase.
Why Feature Flags?
Decoupling Deployment from Release
Traditional deployments tie code deployment directly to feature availability. Feature flags break this coupling, giving you control over when features go live.
The following diagram illustrates the difference between traditional deployment flows and feature flag-enabled releases. You'll notice how feature flags add multiple decision points between deployment and full availability, giving your team granular control over who sees what.
Traditional:
Code complete → Deploy to production → Feature live for everyone
With feature flags:
Code complete → Deploy to production → Feature off
→ Enable for internal team
→ Enable for beta users
→ Enable for 10% of users
→ Enable for everyone
This progressive rollout catches issues early while limiting their blast radius.
Use Cases
- Progressive rollouts: Gradually release to more users
- Kill switches: Instantly disable problematic features
- A/B testing: Compare feature variants
- Beta programs: Early access for specific users
- Operational toggles: Disable expensive features during incidents
Each use case has different requirements for flag lifetime and management.
Flag Types
Release Flags
Release flags control the rollout of new features. They're temporary by nature and should be removed once the feature is fully deployed. Here's the basic pattern you'll use when implementing a release flag in your application.
// Temporary: Remove after full rollout
if (Feature::enabled('new_checkout_flow')) {
return $this->newCheckout($cart);
}
return $this->legacyCheckout($cart);
Lifecycle: Days to weeks, then remove.
Experiment Flags
Experiment flags enable A/B testing by directing users to different feature variants. They help you make data-driven decisions about which implementation to keep. You can use PHP's match expression to cleanly route users to the appropriate variant based on the flag evaluation.
// Temporary: Compare variants for data
$variant = Feature::variant('pricing_page', ['control', 'variant_a', 'variant_b']);
return match ($variant) {
'variant_a' => view('pricing.variant-a'),
'variant_b' => view('pricing.variant-b'),
default => view('pricing.control'),
};
Lifecycle: Weeks to months, then remove losing variants.
Operational Flags
Operational flags provide runtime control over system behavior. They're often long-lived and used to disable expensive features during high load or incidents. This pattern lets you instantly turn off resource-intensive features without a deployment.
// Long-lived: Toggle expensive features
if (Feature::enabled('enable_recommendations')) {
$recommendations = $this->recommender->getRecommendations($user);
}
Lifecycle: Permanent, but rarely changed.
Permission Flags
Permission flags control access to features based on user roles, subscription tiers, or other attributes. They're part of your business logic rather than deployment process. Notice how you can pass the user context to the flag evaluation for user-specific targeting.
// Long-lived: Control access to features
if (Feature::enabled('admin_analytics_dashboard', $user)) {
// Show advanced analytics
}
Lifecycle: Permanent, tied to subscription tiers or roles.
Implementation
Basic Feature Flag Service
Start with a simple service that handles the core evaluation logic. You can add complexity as your needs grow. The following service demonstrates the essential building blocks: a global kill switch, user allowlisting, and percentage-based rollouts.
// app/Services/FeatureFlagService.php
class FeatureFlagService
{
public function enabled(string $flag, ?User $user = null): bool
{
$config = $this->getConfig($flag);
if (!$config) {
return false;
}
// Global kill switch
if (!$config['enabled']) {
return false;
}
// User allowlist
if ($user && in_array($user->id, $config['allowed_users'] ?? [])) {
return true;
}
// Percentage rollout
if (isset($config['percentage'])) {
return $this->isInPercentage($user, $flag, $config['percentage']);
}
return $config['enabled'];
}
private function isInPercentage(?User $user, string $flag, int $percentage): bool
{
// Consistent hashing: Same user always gets same result
$identifier = $user?->id ?? request()->ip();
$hash = crc32("{$flag}:{$identifier}");
return ($hash % 100) < $percentage;
}
}
The consistent hashing approach ensures users don't flip between enabled and disabled states on each request. You'll also notice the service falls back to the request IP for anonymous users, maintaining consistency even before authentication.
Configuration Storage
For simple applications, store flag configuration in a config file. This works well when flags change infrequently. Here's an example configuration that demonstrates various flag patterns including percentage rollouts and user allowlists.
// config/features.php
return [
'new_checkout_flow' => [
'enabled' => env('FEATURE_NEW_CHECKOUT', false),
'percentage' => 25,
'allowed_users' => [1, 2, 3], // Internal team
],
'dark_mode' => [
'enabled' => true,
'percentage' => 100,
],
'experimental_search' => [
'enabled' => env('FEATURE_EXPERIMENTAL_SEARCH', false),
'allowed_users' => [], // Beta testers added dynamically
],
];
Environment variables let you override flags per environment without code changes.
Database-Backed Flags
For dynamic flag management without deployments, store flags in the database. This enables real-time updates through an admin interface. The following implementation adds caching to prevent database queries on every flag check.
// For dynamic flag management
class FeatureFlag extends Model
{
protected $casts = [
'enabled' => 'boolean',
'allowed_users' => 'array',
'rules' => 'array',
];
}
class FeatureFlagService
{
public function enabled(string $flag, ?User $user = null): bool
{
$feature = Cache::remember(
"feature:{$flag}",
60,
fn () => FeatureFlag::where('key', $flag)->first()
);
if (!$feature || !$feature->enabled) {
return false;
}
return $this->evaluateRules($feature, $user);
}
}
The cache layer prevents database queries on every flag check while still allowing changes to propagate within a minute.
Targeting Rules
User Attributes
Targeting rules let you enable features for specific user segments without hardcoding user IDs. This rule engine evaluates multiple conditions against user attributes, enabling sophisticated targeting like "premium users in the US" or "users who signed up after a specific date."
class FeatureFlagService
{
private function evaluateRules(FeatureFlag $feature, ?User $user): bool
{
foreach ($feature->rules as $rule) {
if (!$this->evaluateRule($rule, $user)) {
return false;
}
}
return true;
}
private function evaluateRule(array $rule, ?User $user): bool
{
$attribute = $rule['attribute'];
$operator = $rule['operator'];
$value = $rule['value'];
$userValue = match ($attribute) {
'user_id' => $user?->id,
'email' => $user?->email,
'plan' => $user?->subscription?->plan,
'country' => request()->header('CF-IPCountry'),
'created_at' => $user?->created_at,
default => null,
};
return match ($operator) {
'equals' => $userValue == $value,
'not_equals' => $userValue != $value,
'contains' => str_contains($userValue, $value),
'in' => in_array($userValue, $value),
'greater_than' => $userValue > $value,
'less_than' => $userValue < $value,
default => false,
};
}
}
This rule engine is extensible. You can add new attributes and operators as your targeting needs evolve.
Example Rules
Combine rules to create powerful targeting conditions. All rules must pass for the feature to be enabled. Here are two common patterns you'll encounter in production systems.
// Only for premium users in the US
$feature->rules = [
['attribute' => 'plan', 'operator' => 'in', 'value' => ['premium', 'enterprise']],
['attribute' => 'country', 'operator' => 'equals', 'value' => 'US'],
];
// Only for users created after a date (new users)
$feature->rules = [
['attribute' => 'created_at', 'operator' => 'greater_than', 'value' => '2024-01-01'],
];
Technical Debt Management
Flag Inventory
Feature flags accumulate over time. Regular audits help identify stale flags that should be removed. This Artisan command scans your codebase and database to surface flags that may have outlived their purpose.
// Track all flags in codebase
class FeatureFlagAudit extends Command
{
public function handle(): void
{
$flags = FeatureFlag::all();
foreach ($flags as $flag) {
$usages = $this->findUsages($flag->key);
$this->info("{$flag->key}:");
$this->info(" Created: {$flag->created_at}");
$this->info(" Usages: " . count($usages));
$this->info(" Status: " . ($flag->enabled ? 'Enabled' : 'Disabled'));
if ($flag->created_at < now()->subMonths(3) && $flag->enabled) {
$this->warn(" ⚠️ Flag is old and still enabled - consider removing");
}
}
}
private function findUsages(string $flag): array
{
// Search codebase for flag references
$output = shell_exec("grep -r \"Feature::enabled('{$flag}'\" app/");
return array_filter(explode("\n", $output));
}
}
Running this audit monthly catches flags that have outlived their purpose. The three-month threshold is a reasonable starting point, though you may adjust based on your release cadence.
Expiration Dates
Set expiration dates on release flags to enforce cleanup. Alert the team when flags are about to expire. This scheduled command can run daily to give your team advance notice before flags become overdue.
class FeatureFlag extends Model
{
protected $casts = [
'expires_at' => 'datetime',
];
}
// Alert on expiring flags
class CheckExpiringFlags extends Command
{
public function handle(): void
{
$expiring = FeatureFlag::where('expires_at', '<', now()->addWeeks(2))
->where('expires_at', '>', now())
->get();
foreach ($expiring as $flag) {
Notification::send(
User::admins()->get(),
new FlagExpiringNotification($flag)
);
}
}
}
Cleanup Process
Before deleting a flag, verify it's no longer referenced in code. This prevents runtime errors. The following command enforces a safe deletion workflow by checking for code references before allowing removal.
// Command to remove old flags
class RemoveFeatureFlag extends Command
{
protected $signature = 'feature:remove {flag}';
public function handle(): void
{
$flag = $this->argument('flag');
// Check for code usages
$usages = $this->findUsages($flag);
if (!empty($usages)) {
$this->error("Flag '{$flag}' is still used in code:");
foreach ($usages as $usage) {
$this->line(" - {$usage}");
}
$this->error("Remove code references before deleting the flag.");
return;
}
// Safe to delete
FeatureFlag::where('key', $flag)->delete();
Cache::forget("feature:{$flag}");
$this->info("Flag '{$flag}' deleted successfully.");
}
}
This two-step process ensures you never delete a flag that's still in use.
Testing with Feature Flags
Unit Tests
Use a fake feature flag service in tests to control flag states without hitting the database. Laravel's facade fake pattern makes this straightforward, letting you test both enabled and disabled paths.
class CheckoutTest extends TestCase
{
public function test_new_checkout_flow_when_enabled()
{
Feature::fake(['new_checkout_flow' => true]);
$response = $this->post('/checkout', $this->validCartData());
$response->assertViewIs('checkout.new');
}
public function test_legacy_checkout_flow_when_disabled()
{
Feature::fake(['new_checkout_flow' => false]);
$response = $this->post('/checkout', $this->validCartData());
$response->assertViewIs('checkout.legacy');
}
}
Testing Both Paths
Use data providers to test both flag states with the same test logic. This ensures both code paths work correctly and catches regressions in either implementation.
class CheckoutTest extends TestCase
{
/** @dataProvider checkoutFlagProvider */
public function test_checkout_completes_successfully(bool $newCheckoutEnabled)
{
Feature::fake(['new_checkout_flow' => $newCheckoutEnabled]);
$response = $this->post('/checkout', $this->validCartData());
$response->assertSuccessful();
$this->assertDatabaseHas('orders', ['status' => 'completed']);
}
public static function checkoutFlagProvider(): array
{
return [
'new checkout' => [true],
'legacy checkout' => [false],
];
}
}
This pattern catches regressions in the old code path that might otherwise go unnoticed.
Monitoring and Analytics
Flag Evaluation Tracking
Track every flag evaluation to understand usage patterns and identify stale flags. Adding metrics to your feature flag service provides visibility into how flags are being used across your application.
class FeatureFlagService
{
public function enabled(string $flag, ?User $user = null): bool
{
$result = $this->evaluate($flag, $user);
// Track evaluation
$this->metrics->increment('feature_flag.evaluation', [
'flag' => $flag,
'result' => $result ? 'enabled' : 'disabled',
'user_id' => $user?->id,
]);
return $result;
}
}
Dashboard Data
Aggregate evaluation data into a dashboard that shows flag usage over time. This analytics class provides the foundation for understanding your flags' impact and rollout progress.
class FeatureFlagAnalytics
{
public function getStats(string $flag, Carbon $since): array
{
return [
'total_evaluations' => $this->countEvaluations($flag, $since),
'enabled_count' => $this->countEvaluations($flag, $since, true),
'disabled_count' => $this->countEvaluations($flag, $since, false),
'unique_users' => $this->uniqueUsers($flag, $since),
'by_day' => $this->evaluationsByDay($flag, $since),
];
}
}
This data helps you understand rollout progress and identify when a flag can be removed.
Best Practices
Naming Conventions
Use clear, descriptive names that indicate the flag's purpose and type. Include a prefix for the flag category. Good naming makes flags self-documenting and easier to audit.
// Good: Clear and descriptive
'enable_new_checkout_v2'
'experiment_pricing_variant_a'
'ops_disable_email_sending'
'permission_advanced_analytics'
// Bad: Vague or confusing
'feature_1'
'test_flag'
'new_thing'
'temp'
Default Values
Always default to the safe behavior, typically with the flag disabled. Handle evaluation errors gracefully. This defensive approach prevents flags from accidentally enabling features during outages.
// Always default to safe behavior (usually off)
public function enabled(string $flag, ?User $user = null): bool
{
try {
return $this->evaluate($flag, $user);
} catch (Exception $e) {
Log::error('Feature flag evaluation failed', [
'flag' => $flag,
'error' => $e->getMessage(),
]);
// Default to disabled on errors
return false;
}
}
Code Organization
Keep flag checks at entry points rather than scattered throughout your code. This makes flags easier to find and remove. The following examples show the difference between clean and problematic flag placement.
// Group flag checks at entry points, not scattered throughout
class CheckoutController
{
public function process(Request $request)
{
// Single flag check at top
if (Feature::enabled('new_checkout_flow')) {
return app(NewCheckoutService::class)->process($request);
}
return app(LegacyCheckoutService::class)->process($request);
}
}
// Bad: Flags scattered throughout
class CheckoutService
{
public function process()
{
if (Feature::enabled('new_checkout_flow')) { /* ... */ }
// ... 50 lines later ...
if (Feature::enabled('new_checkout_flow')) { /* ... */ }
// ... more scattered checks ...
}
}
Centralized flag checks make it obvious what code to remove when the flag is cleaned up.
Conclusion
Feature flags enable safe, incremental releases by separating deployment from feature activation. Use release flags for gradual rollouts, experiment flags for A/B tests, and operational flags for kill switches. Implement targeting rules for precise control over who sees what. Most importantly, manage flag lifecycle aggressively; set expiration dates, track usage, and remove flags once features are fully rolled out. The power of feature flags comes with the responsibility of keeping them under control.