Progressive Delivery: Combining Feature Flags with Deployment Strategy

Philip Rehberger Apr 13, 2026 6 min read

Deploying code and releasing features are two different things. Progressive delivery decouples them so you can ship continuously while controlling who sees what, and when.

Progressive Delivery: Combining Feature Flags with Deployment Strategy

The phrase "deploy to production" conflates two distinct actions: putting code on servers, and making features available to users. Progressive delivery is the discipline of separating them — getting code deployed to production continuously while controlling feature exposure through flags, gradual rollouts, and targeted releases.

When done well, you stop having "big bang" releases and start having a continuous stream of small, controlled exposures. Bugs are caught with limited blast radius. Experiments run without additional deployments. Rollback becomes instant.

The Difference Between Deploy and Release

Without feature flags:

Code merged → CI passes → Deploy to production → All users see new feature

With progressive delivery:

Code merged → CI passes → Deploy to production → Feature is off
               ↓
Gradual rollout: 1% → 10% → 50% → 100%
               ↓
Full release when metrics look good

The code deployment happens once. The release is a controlled, observable event.

Building a Feature Flag System

You can build a simple but effective feature flag system without a commercial tool:

// app/Services/FeatureFlagService.php

class FeatureFlagService
{
    public function __construct(private Cache $cache) {}

    public function isEnabled(string $flag, ?User $user = null): bool
    {
        $config = $this->getFlag($flag);

        if (!$config) {
            return false; // Unknown flags default to off
        }

        if ($config['status'] === 'off') {
            return false;
        }

        if ($config['status'] === 'on') {
            return true;
        }

        if ($config['status'] === 'percentage') {
            return $this->isInPercentage($config['percentage'], $flag, $user);
        }

        if ($config['status'] === 'users' && $user) {
            return in_array($user->id, $config['user_ids'], true);
        }

        if ($config['status'] === 'roles' && $user) {
            return $user->hasAnyRole($config['roles']);
        }

        return false;
    }

    private function isInPercentage(int $percentage, string $flag, ?User $user): bool
    {
        // Use stable hash so the same user always gets the same result
        $identifier = $user ? "user:{$user->id}" : session()->getId();
        $hash = crc32($flag . ':' . $identifier);
        $bucket = abs($hash) % 100;
        return $bucket < $percentage;
    }

    private function getFlag(string $flag): ?array
    {
        return $this->cache->remember(
            "feature_flags:{$flag}",
            300, // Cache for 5 minutes
            fn () => FeatureFlag::where('key', $flag)->first()?->toArray()
        );
    }
}

The database table:

// Migration
Schema::create('feature_flags', function (Blueprint $table) {
    $table->id();
    $table->string('key')->unique();
    $table->string('description');
    $table->enum('status', ['off', 'on', 'percentage', 'users', 'roles']);
    $table->integer('percentage')->nullable();
    $table->json('user_ids')->nullable();
    $table->json('roles')->nullable();
    $table->timestamps();
});

Using Flags in Code

Feature flags should be used at decision points, not sprinkled throughout a feature's internals:

// Good: flag at the entry point
class CheckoutController
{
    public function store(CheckoutRequest $request): Response
    {
        if ($this->flags->isEnabled('enhanced-checkout', $request->user())) {
            return $this->enhancedCheckout->process($request);
        }

        return $this->legacyCheckout->process($request);
    }
}

// Avoid: flag buried inside a service — harder to clean up later
class PaymentService
{
    public function charge(Order $order): void
    {
        if ($this->flags->isEnabled('new-payment-flow')) { // Hard to find later
            // ...
        }
    }
}

For frontend, expose flag state via an API endpoint:

class FeatureFlagController
{
    public function index(Request $request): JsonResponse
    {
        $user = $request->user();

        // Only expose flags relevant to the frontend
        $frontendFlags = [
            'enhanced-checkout',
            'new-dashboard',
            'beta-reporting',
        ];

        $flags = collect($frontendFlags)->mapWithKeys(fn ($flag) => [
            $flag => $this->flags->isEnabled($flag, $user),
        ]);

        return response()->json($flags);
    }
}
// JavaScript: check flags before rendering
const { data: flags } = await axios.get('/api/feature-flags');

if (flags['enhanced-checkout']) {
    renderEnhancedCheckout();
} else {
    renderLegacyCheckout();
}

Gradual Rollout Strategy

A typical rollout progression for a significant feature:

Day 1:  Internal team only (users: specific user IDs)
Day 3:  Beta users and internal (roles: beta)
Day 5:  1% of all users (percentage: 1)
Day 7:  Review metrics — error rates, conversion, performance
Day 8:  10% if metrics look good
Day 10: 50%
Day 12: 100% → archive the flag

At each step, you monitor:

// Track which variant users are in for metric segmentation
class RolloutMetricsService
{
    public function trackConversion(User $user, string $event): void
    {
        $variant = $this->flags->isEnabled('enhanced-checkout', $user)
            ? 'enhanced'
            : 'legacy';

        Analytics::track([
            'user_id' => $user->id,
            'event' => $event,
            'properties' => [
                'checkout_variant' => $variant,
                'feature_flag' => 'enhanced-checkout',
            ],
        ]);
    }
}

This lets you compare conversion rates, error rates, and performance metrics between the control (legacy) and treatment (enhanced) groups.

Integrating With Deployment Pipelines

Feature flags let you deploy continuously from the main branch without needing long-lived feature branches:

# .github/workflows/deploy.yml
name: Deploy

on:
  push:
    branches: [main]

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Deploy to production
        run: node scripts/deploy/deploy.cjs

      # No feature flag changes here — flags are changed independently
      # by the product team through the admin UI or API

The deployment always deploys all code. Feature visibility is controlled separately through the flag admin interface.

Kill Switches

Not every flag is a gradual rollout. Kill switches let you instantly disable a problematic feature:

// A kill switch that defaults to ON (feature enabled) and can be turned off
class ApiRateLimitService
{
    public function shouldRateLimit(Request $request): bool
    {
        // Kill switch: disable rate limiting immediately if it's causing issues
        if (!$this->flags->isEnabled('api-rate-limiting-enabled')) {
            return false;
        }

        return $this->isOverLimit($request);
    }
}
// Protecting an integration with a kill switch
class SlackNotificationService
{
    public function send(Notification $notification): void
    {
        if (!$this->flags->isEnabled('slack-notifications')) {
            Log::debug('Slack notifications disabled via feature flag');
            return;
        }

        $this->slack->post($notification);
    }
}

When Slack is having an incident and your notifications are failing or slowing down your app, you flip the flag and stop sending immediately.

Commercial Feature Flag Tools

For larger teams, commercial tools add significant capabilities:

LaunchDarkly: Real-time flag evaluation, built-in A/B testing, flag analytics, SDKs for every language. Flags evaluated server-side with sub-millisecond latency via persistent connections.

Unleash: Open-source, self-hostable, with enterprise features. Good choice if you want control over data.

Flagsmith: Open-source with cloud option, supports A/B testing and remote configuration.

AWS AppConfig: Integrated with AWS, supports deployment validators that can auto-roll back when error rates spike.

For a team shipping several features per week, a commercial tool pays for itself quickly in reduced release coordination overhead.

Flag Hygiene: Clean Up After Yourself

The most common problem with feature flags is accumulation. Flags that were supposed to be temporary live for years and become coupling points that nobody wants to remove.

Establish a cleanup process:

// Track when a flag was fully rolled out
Schema::table('feature_flags', function (Blueprint $table) {
    $table->timestamp('fully_rolled_out_at')->nullable();
    $table->string('ticket_to_clean_up')->nullable(); // e.g., 'JIRA-1234'
});

Add a rule: when a flag reaches 100%, create a cleanup ticket before moving on. Set the fully_rolled_out_at date. If a flag has been at 100% for more than 30 days, it should be removed from the code.

A flag at 100% that is not removed is dead code plus complexity. The branch in your code will never execute. Remove it.

Progressive delivery is a discipline, not just a tool. The real value comes from changing how your team thinks about deploying code and releasing features — as two separate, independently controlled events.

Building secure, reliable systems? We help teams deliver software they can trust. scopeforged.com

Share this article

Related Articles

Need help with your project?

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