Feature Flags

Ship code to production without exposing it to all users. Feature flags let you merge a new feature, test it with your internal team first, roll it out to 10% of users, and then go to 100% when you are confident it works. If something breaks, disable the flag instantly — no rollback, no deploy, no downtime.

Why This Matters

Without feature flags, every deploy is all-or-nothing: you push code and every user gets it at once. That means you need to be 100% confident before merging. Feature flags change this dynamic. You can merge incomplete or experimental code behind a flag, iterate on it in production with real data, and expose it gradually. This is how companies like GitHub, Stripe, and Netflix ship software.

Real-World Scenarios

Scenario 1: Rolling Out a Dashboard Redesign

You have built a completely new dashboard. It works in staging, but you are not sure how it performs with real data at scale, and you want to make sure users can actually find the features they need in the new layout.

  1. Create a flag called new_dashboard.
  2. Set the rules to {"org_ids": [1]} — your own organization. Use the new dashboard yourself for a few days.
  3. Expand to {"percentage": 10} — 10% of users see the new dashboard. Monitor error rates and support tickets.
  4. Increase to {"percentage": 50}, then {"percentage": 100}.
  5. Once everyone is on the new dashboard, remove the flag and delete the old code.

Scenario 2: Beta Feature for a Specific Customer

An enterprise customer has asked for a bulk export feature. You have built it, but it is only relevant to their use case right now and you are not ready to support it broadly.

  1. Create a flag called bulk_export.
  2. Set the rules to {"org_ids": [47]} — just their organization.
  3. The feature is live for them immediately. They test it, give feedback, and you iterate.
  4. When it is polished, remove the org_ids restriction and enable it for everyone.

Scenario 3: Emergency Kill Switch

You deployed a new real-time collaboration feature on Friday afternoon (we have all been there). Over the weekend, it starts causing database connection spikes under load.

  1. The feature was behind a flag called realtime_collab.
  2. Your on-call engineer sets the flag to enabled: false from the admin panel.
  3. The feature is instantly disabled for all users. No code rollback needed. The database recovers.
  4. On Monday, the team fixes the connection pooling issue, re-enables the flag for internal testing, verifies the fix, and rolls it back out.

The FeatureFlag Model

Each flag is stored with the following attributes:

  • key — Unique identifier used in code (e.g., new_dashboard, bulk_export, ai_assistant). This is what developers reference in if checks. Use snake_case and be descriptive.
  • name — Human-readable label for the admin panel (e.g., "New Dashboard", "Bulk Export"). This is what non-technical team members see.
  • description — Explains what the flag controls, why it exists, and any context for someone unfamiliar with the feature. Good descriptions prevent "what does this flag do?" conversations months later.
  • enabled — Global on/off switch. When false, the feature is disabled for everyone regardless of targeting rules. This is the kill switch.
  • rules — JSON targeting rules that determine who sees the feature when the flag is enabled. See below.

Targeting Rules

When a flag is enabled, its rules object determines who sees the feature:

{
    "org_ids": [1, 5, 12],
    "user_ids": [42, 87],
    "percentage": 25
}
  • org_ids — Enable for specific organizations. Use this for beta testing with specific customers, or to give your own team access to unreleased features.
  • user_ids — Enable for specific users. Use this for dogfooding with individual team members, or for giving a specific person early access (e.g., a product advisor who wants to preview a feature).
  • percentage — Enable for a percentage of users (0-100). Use this for gradual rollouts where you want to monitor impact at scale before going to 100%.

Rules are evaluated with OR logic: if a user matches any rule, the flag is active for them. A user in user_ids sees the feature even if they are not in the percentage group.

If the rules object is empty or null and the flag is enabled, the feature is active for everyone. This is the state you want after a successful rollout — the flag is just an on/off switch at that point.

Deterministic Percentage Rollout

Percentage-based targeting uses a deterministic hash seeded with the user ID (or organization ID). This means:

  • User 42 always gets the same result for flag new_dashboard. No flickering between page loads.
  • If you increase the percentage from 10% to 25%, every user who was already in the 10% group stays in. New users are added, but no one is removed.
  • Different flags produce different hash results, so user 42 might be in the 10% group for flag A but not for flag B. The groups are not correlated.

This is important for user experience. If a feature appeared and disappeared randomly on page reload, users would file bug reports.

Backend Usage

Check if a flag is active anywhere in your application code:

use App\Models\FeatureFlag;

// Check for the current user and their organization
if (FeatureFlag::isEnabledFor($org->id, $user->id)) {
    // Show the new feature
    return response()->json(['data' => $this->newExportFormat()]);
}

// Fall back to the old behavior
return response()->json(['data' => $this->legacyExportFormat()]);

The isEnabledFor method accepts nullable parameters. Pass null for either value if it does not apply to the current context (e.g., an unauthenticated endpoint, or a system job that runs outside a user context):

// Check only against organization
FeatureFlag::isEnabledFor($org->id, null);

// Check only against user
FeatureFlag::isEnabledFor(null, $user->id);

// Check without any targeting context (only uses the global enabled flag)
FeatureFlag::isEnabledFor(null, null);

Frontend Usage

The frontend fetches all flags pre-evaluated for the authenticated user and their current organization. There is no client-side evaluation logic — the server does all the work and returns a simple true/false map.

GET /api/feature-flags

Response:

{
    "new_dashboard": true,
    "bulk_export": false,
    "ai_assistant": true,
    "realtime_collab": false
}

In your React components, use the flags from this endpoint to conditionally render features:

const { data: flags } = useFeatureFlags();

return (
    <div>
        {flags?.new_dashboard ? <NewDashboard /> : <LegacyDashboard />}

        {flags?.bulk_export && <BulkExportButton />}
    </div>
);

The useFeatureFlags hook calls GET /api/feature-flags and caches the result with TanStack Query. Flags are refreshed when the user navigates between pages or when the query is manually invalidated.

Admin CRUD Endpoints

GET    /api/admin/feature-flags           # List all flags with their rules and status
POST   /api/admin/feature-flags           # Create a new flag
PUT    /api/admin/feature-flags/{id}      # Update a flag (name, rules, enabled status)
DELETE /api/admin/feature-flags/{id}      # Delete a flag permanently

Create a Flag

{
    "key": "new_dashboard",
    "name": "New Dashboard",
    "description": "Redesigned dashboard with improved navigation and real-time widgets. Rolling out gradually starting with internal team.",
    "enabled": true,
    "rules": {
        "org_ids": [1]
    }
}

Update a Flag (Expand Rollout)

{
    "rules": {
        "percentage": 25
    }
}

Disable a Flag (Kill Switch)

{
    "enabled": false
}

Setting enabled to false immediately disables the feature for all users, regardless of targeting rules. The rules are preserved so you can re-enable the flag with the same targeting later.

Delete a Flag

Delete flags only after the feature has been fully rolled out and the flag check has been removed from the codebase. If you delete a flag that is still referenced in code, isEnabledFor will return false (feature disabled) — a safe default, but probably not what you want.