Feature Flags in Production: A Practical Guide

Reverend Philip Dec 6, 2025 8 min read

Deploy with confidence using feature flags. Learn implementation patterns, gradual rollouts, and A/B testing integration.

Feature flags (also called feature toggles) separate code deployment from feature release. You can deploy code to production without exposing it to users, then gradually roll out features, run A/B tests, and quickly disable problematic features. This guide covers implementation patterns and best practices.

Why Feature Flags?

Traditional Deployment

In the traditional model, deployment and release are the same event.

Code Complete -> Deploy -> All Users See Feature

If something goes wrong, you redeploy or rollback.

With Feature Flags

Feature flags decouple deployment from release, giving you fine-grained control over who sees what.

Code Complete -> Deploy (hidden) -> Enable for 1% -> 10% -> 50% -> 100%

You can:

  • Test in production with real data
  • Roll back instantly without deployment
  • Enable features for specific users
  • Run A/B experiments

Types of Feature Flags

Release Flags

Short-lived flags for deploying incomplete features. Use these when you want to merge work-in-progress to main without exposing it to users.

if (Feature::active('new-checkout')) {
    return $this->newCheckoutProcess();
}
return $this->legacyCheckout();

Remove after feature is stable and fully rolled out.

Experiment Flags

For A/B testing where you need to measure the impact of changes.

if (Feature::active('pricing-experiment-b')) {
    return view('pricing.variant-b');
}
return view('pricing.control');

Track conversion rates, then remove after experiment concludes.

Operational Flags

Long-lived flags for controlling system behavior. These act as runtime configuration switches.

if (Feature::active('maintenance-mode')) {
    return response()->view('maintenance', [], 503);
}

if (Feature::active('enable-caching')) {
    return Cache::remember($key, 3600, fn() => $this->fetch());
}

May stay in codebase indefinitely.

Permission Flags

Control access to premium features based on user attributes or subscription level.

if (Feature::for($user)->active('advanced-analytics')) {
    return view('analytics.advanced');
}
return view('analytics.basic');

Implementation

Simple Database-Backed Flags

Start with a straightforward database implementation. This gives you a UI for managing flags without external dependencies.

// Migration
Schema::create('feature_flags', function (Blueprint $table) {
    $table->id();
    $table->string('name')->unique();
    $table->boolean('enabled')->default(false);
    $table->integer('percentage')->default(100);
    $table->json('rules')->nullable();
    $table->timestamps();
});

// Model
class FeatureFlag extends Model
{
    protected $casts = [
        'enabled' => 'boolean',
        'rules' => 'array',
    ];

    public function isActive(?User $user = null): bool
    {
        if (!$this->enabled) {
            return false;
        }

        if ($this->percentage < 100) {
            return $this->rolloutCheck($user);
        }

        if ($this->rules && $user) {
            return $this->evaluateRules($user);
        }

        return true;
    }

    private function rolloutCheck(?User $user): bool
    {
        $identifier = $user?->id ?? request()->ip();
        $hash = crc32($this->name . $identifier);
        return ($hash % 100) < $this->percentage;
    }
}

The rolloutCheck method uses a hash to ensure consistent assignment. The same user always gets the same result, which is essential for a good user experience during gradual rollouts.

Service Class

Wrap the flag lookup in a service class with caching to avoid database queries on every check.

class Feature
{
    private static ?User $user = null;
    private static array $cache = [];

    public static function for(User $user): static
    {
        static::$user = $user;
        return new static;
    }

    public static function active(string $name): bool
    {
        if (!isset(static::$cache[$name])) {
            $flag = FeatureFlag::where('name', $name)->first();
            static::$cache[$name] = $flag?->isActive(static::$user) ?? false;
        }

        return static::$cache[$name];
    }

    public static function clearCache(): void
    {
        static::$cache = [];
        static::$user = null;
    }
}

Call Feature::clearCache() after updating flags to ensure changes take effect immediately.

Laravel Pennant

Laravel's official feature flag package provides a more robust solution with built-in storage drivers and Blade directives.

composer require laravel/pennant
php artisan vendor:publish --provider="Laravel\Pennant\PennantServiceProvider"
php artisan migrate

Define features using closures that receive the user context.

// Define features
// app/Providers/AppServiceProvider.php
use Laravel\Pennant\Feature;

public function boot(): void
{
    Feature::define('new-checkout', function (User $user) {
        return $user->is_beta_tester;
    });

    Feature::define('redesigned-dashboard', function (User $user) {
        return match(true) {
            $user->isAdmin() => true,
            $user->created_at->gt(now()->subMonth()) => false,
            default => Lottery::odds(1, 10)->choose(),
        };
    });
}

// Check features
if (Feature::active('new-checkout')) {
    // ...
}

// For specific user
if (Feature::for($user)->active('new-checkout')) {
    // ...
}

// In Blade
@feature('new-checkout')
    <x-new-checkout />
@else
    <x-legacy-checkout />
@endfeature

The Lottery::odds(1, 10) syntax provides a clean way to enable features for a random subset of users, perfect for gradual rollouts.

Gradual Rollout

Percentage-Based

Roll out to a percentage of users while ensuring consistent assignment.

Feature::define('new-search', function (User $user) {
    // Consistent assignment based on user ID
    return crc32('new-search' . $user->id) % 100 < 25; // 25%
});

The hash ensures users do not flip between variants on different requests, which would create a confusing experience.

User Segment Rollout

Roll out in phases, starting with the lowest-risk users and expanding over time.

Feature::define('new-feature', function (User $user) {
    // Phase 1: Internal users
    if ($user->email_domain === 'company.com') {
        return true;
    }

    // Phase 2: Beta testers
    if ($user->is_beta_tester) {
        return true;
    }

    // Phase 3: New users
    if ($user->created_at->gt(now()->subWeek())) {
        return true;
    }

    // Phase 4: Everyone
    return false;
});

This pattern lets you validate with internal users first, then beta testers, then new users (who have less muscle memory), and finally everyone.

Time-Based Rollout

Enable features based on date for seasonal or scheduled releases.

Feature::define('holiday-theme', function () {
    $now = now();
    return $now->month === 12 && $now->day >= 15;
});

A/B Testing Integration

Tracking Variants

When running experiments, track which variant each user sees to correlate with conversion data.

Feature::define('pricing-experiment', function (User $user) {
    $variant = crc32('pricing' . $user->id) % 3;

    // Track assignment
    Analytics::track('experiment_assigned', [
        'experiment' => 'pricing',
        'variant' => $variant,
        'user_id' => $user->id,
    ]);

    return $variant; // 0, 1, or 2
});

// Usage
$variant = Feature::value('pricing-experiment');
return view("pricing.variant-{$variant}");

Use Feature::value() instead of Feature::active() when you need the actual variant value rather than a boolean.

Conversion Tracking

Track conversions alongside the variant to measure experiment impact.

// When user converts
public function subscribe(Request $request)
{
    $user = $request->user();
    $variant = Feature::value('pricing-experiment');

    Analytics::track('subscription_created', [
        'experiment' => 'pricing',
        'variant' => $variant,
        'user_id' => $user->id,
        'plan' => $request->plan,
    ]);

    // ... create subscription
}

Analyze this data to determine which variant performs best before rolling out the winner to everyone.

Operational Patterns

Circuit Breaker

Use a feature flag as an automatic circuit breaker that disables functionality when failures accumulate.

Feature::define('external-api-enabled', function () {
    $failures = Cache::get('external-api-failures', 0);
    return $failures < 5;
});

// Usage
if (Feature::active('external-api-enabled')) {
    try {
        return $this->callExternalApi();
    } catch (Exception $e) {
        Cache::increment('external-api-failures');
        Cache::put('external-api-failures', Cache::get('external-api-failures'), 300);
        throw $e;
    }
}
return $this->fallbackResponse();

The 5-minute cache TTL allows the circuit to reset automatically, providing self-healing behavior.

Kill Switch

For emergency disabling of critical functionality.

// For emergency disabling
Feature::define('payments-enabled', function () {
    return Cache::get('payments-kill-switch', true);
});

// Emergency disable
Cache::put('payments-kill-switch', false);

Using cache instead of database provides faster propagation and works even if your database is under stress.

Maintenance Mode

Implement read-only mode for maintenance windows.

Feature::define('read-only-mode', function () {
    return config('app.read_only');
});

// Middleware
public function handle($request, Closure $next)
{
    if (Feature::active('read-only-mode') && $request->isMethod('POST')) {
        return response()->json([
            'error' => 'System is in read-only mode for maintenance'
        ], 503);
    }
    return $next($request);
}

This pattern lets you deploy database migrations or perform other maintenance while keeping the site partially available.

Admin Interface

Build an interface for non-technical team members to manage flags.

// Controller for managing flags
class FeatureFlagController extends Controller
{
    public function index()
    {
        $flags = FeatureFlag::all();
        return view('admin.features.index', compact('flags'));
    }

    public function update(Request $request, FeatureFlag $flag)
    {
        $flag->update($request->validate([
            'enabled' => 'boolean',
            'percentage' => 'integer|min:0|max:100',
        ]));

        Feature::clearCache();

        return back()->with('success', 'Feature flag updated');
    }
}

Consider adding audit logging to track who changed what and when.

Testing

Unit Tests

Test both enabled and disabled states for each feature.

use Laravel\Pennant\Feature;

public function test_new_checkout_for_beta_users()
{
    $user = User::factory()->betaTester()->create();

    Feature::for($user)->activate('new-checkout');

    $this->actingAs($user)
        ->get('/checkout')
        ->assertSee('New Checkout Experience');
}

public function test_legacy_checkout_for_regular_users()
{
    $user = User::factory()->create();

    Feature::for($user)->deactivate('new-checkout');

    $this->actingAs($user)
        ->get('/checkout')
        ->assertSee('Standard Checkout');
}

Feature Test Helpers

Test all variants to ensure each code path works correctly.

// Test all variants
public function test_pricing_page_variants()
{
    collect(['control', 'variant-a', 'variant-b'])->each(function ($variant) {
        Feature::define('pricing-variant', fn() => $variant);

        $response = $this->get('/pricing');
        $response->assertViewIs("pricing.{$variant}");
    });
}

This ensures you do not break less-traveled code paths that only run for specific variants.

Best Practices

Naming Conventions

Use descriptive, consistent naming that indicates the flag's purpose.

// Good - descriptive and consistent
'enable-new-checkout-flow'
'experiment-pricing-2024-q1'
'ops-enable-redis-cache'

// Bad - vague or inconsistent
'flag1'
'new_thing'
'test'

Prefixes like enable-, experiment-, and ops- help categorize flags and indicate their lifecycle.

Flag Lifecycle

Follow a consistent lifecycle to prevent flag accumulation.

  1. Create: Add flag, default off
  2. Deploy: Ship code behind flag
  3. Test: Enable for internal users
  4. Rollout: Gradual percentage increase
  5. Cleanup: Remove flag and dead code

Avoid Flag Debt

Track flag age and alert on stale flags that should be removed.

// Track flag age
class FeatureFlag extends Model
{
    public function isStale(): bool
    {
        return $this->created_at->lt(now()->subMonths(3));
    }
}

// Alert on old flags
// In scheduled command
FeatureFlag::where('created_at', '<', now()->subMonths(3))
    ->get()
    ->each(fn($flag) => Log::warning("Stale feature flag: {$flag->name}"));

Feature flags that live forever become technical debt. Set a reminder to clean them up.

Documentation

Document each flag's purpose, owner, and expected removal date.

Feature::define('new-checkout', function (User $user) {
    // FEATURE: New checkout flow with Apple Pay support
    // OWNER: payments-team@company.com
    // CREATED: 2024-01-15
    // EXPECTED REMOVAL: 2024-03-01
    // ROLLOUT STATUS: 50% of users
    return $user->is_beta_tester || Lottery::odds(1, 2)->choose();
});

This metadata helps future developers understand the flag's context and prevents orphaned flags.

Conclusion

Feature flags are a powerful tool for safe, gradual deployments. Start simple with database-backed flags or Laravel Pennant, add percentage rollouts for risk management, and integrate with analytics for A/B testing. Remember to clean up old flags;the goal is controlled releases, not permanent complexity.

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.