Feature Flags Best Practices

Reverend Philip Jan 8, 2026 9 min read

Control feature rollouts with flags. Learn flag types, targeting rules, technical debt management, and operational excellence.

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.

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.

// 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.

// 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.

// 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.

// 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.

// 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.

Configuration Storage

For simple applications, store flag configuration in a config file. This works well when flags change infrequently.

// 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.

// 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.

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.

// 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.

// 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.

Expiration Dates

Set expiration dates on release flags to enforce cleanup. Alert the team when flags are about to expire.

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.

// 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.

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.

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.

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.

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: 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.

// 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.

// 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.

Share this article

Related Articles

Distributed Locking Patterns

Coordinate access to shared resources across services. Implement distributed locks with Redis, ZooKeeper, and databases.

Jan 16, 2026

API Design First Development

Design APIs before implementing them. Use OpenAPI specifications, mock servers, and contract-first workflows.

Jan 15, 2026

Need help with your project?

Let's discuss how we can help you build reliable software.