Subflag Rails

Feature flags that return strings, numbers, JSON — not just booleans.

limit = subflag_value(:upload_limit_mb, default: 10)
tier_config = subflag_value(:pricing_tiers, default: { basic: 5, pro: 50 })
welcome = subflag_value(:welcome_message, default: "Hello")

Target different values to different users:

# config/initializers/subflag.rb
Subflag::Rails.configure do |config|
  config.backend = :active_record

  config.user_context do |user|
    { targeting_key: user.id.to_s, plan: user.plan, email: user.email }
  end
end
# Premium users get 100, free users get 10
subflag_value(:max_projects, default: 10)

Self-hosted or cloud. Same API.

Quick Start

gem "subflag-rails"
rails generate subflag:install --backend=active_record
rails db:migrate
# config/routes.rb
mount Subflag::Rails::Engine => "/subflag"

No external dependencies. Admin UI included.


Backends

Choose where your flags live:

Backend Use Case Flags Stored In
:active_record Self-hosted, no external dependencies, built-in admin UI Your database
:subflag Dashboard, environments, percentage rollouts Subflag Cloud
:memory Testing and development In-memory hash

Subflag Cloud

Dashboard, environments, percentage rollouts, and user targeting.

gem "subflag-rails"
gem "subflag-openfeature-provider", ">= 0.3.2"
rails generate subflag:install

Add your API key to Rails credentials:

rails credentials:edit
subflag:
  api_key: sdk-production-your-key-here

Or set the SUBFLAG_API_KEY environment variable.

Memory (Testing)

In-memory flags for tests and local development.

rails generate subflag:install --backend=memory

Set flags programmatically:

Subflag::Rails.provider.set(:new_checkout, true)
Subflag::Rails.provider.set(:max_projects, 100)

Usage

Controllers & Views

Helpers are automatically available and scoped to current_user:

# Controller
class ProjectsController < ApplicationController
  def index
    if subflag_enabled?(:new_dashboard)
      # show new dashboard
    end

    @max_projects = subflag_value(:max_projects, default: 3)
  end
end
<!-- View -->
<% if subflag_enabled?(:new_checkout) %>
  <%= render "new_checkout" %>
<% end %>

<h1><%= subflag_value(:headline, default: "Welcome") %></h1>

<p>You can create <%= subflag_value(:max_projects, default: 3) %> projects</p>

Flag Accessor DSL

For multiple flag checks, use the flag accessor:

flags = subflag_for  # auto-scoped to current_user

if flags.beta_feature?
  headline = flags.welcome_message(default: "Hello!")
  max = flags.max_projects(default: 3)
end

Or use Subflag.flags directly:

# With user context
flags = Subflag.flags(user: current_user)
flags.new_checkout?              # => true/false
flags.max_projects(default: 3)   # => 100

# Bracket access for exact flag names
flags["my-exact-flag", default: "value"]

# Full evaluation details
result = flags.evaluate(:max_projects, default: 3)
result.value    # => 100
result.variant  # => "premium"
result.reason   # => :targeting_match

Flag Types

The default value determines the expected type:

# Boolean (? suffix, default optional)
subflag_enabled?(:new_checkout)           # default: false
subflag_enabled?(:new_checkout, default: true)

# String
subflag_value(:headline, default: "Welcome")

# Integer
subflag_value(:max_projects, default: 3)

# Float
subflag_value(:tax_rate, default: 0.08)

# Hash/Object
subflag_value(:feature_limits, default: { max_items: 10 })

User Targeting

Configure how to extract context from user objects:

# config/initializers/subflag.rb
Subflag::Rails.configure do |config|
  config.user_context do |user|
    {
      targeting_key: user.id.to_s,
      email: user.email,
      plan: user.subscription&.plan_name || "free",
      admin: user.admin?
    }
  end
end

Now flags can return different values based on user attributes:

# In Subflag dashboard: max-projects returns 3 for "free", 100 for "premium"
subflag_value(:max_projects, default: 3)  # => 3 or 100 based on user's plan

Override User Context

# No user context
subflag_enabled?(:public_feature, user: nil)

# Different user
subflag_value(:max_projects, user: admin_user, default: 3)

Flag Naming

Flag names use lowercase letters, numbers, and dashes:

  • Valid: new-checkout, max-api-requests, feature1
  • Invalid: new_checkout, NewCheckout, my flag

In Ruby, use underscores — they're automatically converted to dashes:

subflag_enabled?(:new_checkout)  # looks up "new-checkout"

Request Caching

Enable per-request caching to avoid multiple API calls for the same flag:

# config/application.rb
config.middleware.use Subflag::Rails::RequestCache::Middleware

Now multiple checks for the same flag in one request hit the API only once:

# Without caching: 3 API calls
# With caching: 1 API call (cached for subsequent checks)
subflag_enabled?(:new_checkout)  # API call
subflag_enabled?(:new_checkout)  # Cache hit
subflag_enabled?(:new_checkout)  # Cache hit

Cross-Request Caching

By default, prefetched flags are only cached for the current request. To cache across multiple requests using Rails.cache, set a TTL:

# config/initializers/subflag.rb
Subflag::Rails.configure do |config|
  config.api_key = Rails.application.credentials.subflag_api_key
  config.cache_ttl = 30.seconds  # Cache flags in Rails.cache for 30 seconds
end

With cache_ttl set:

  • First request fetches from API and stores in Rails.cache
  • Subsequent requests (within TTL) read from Rails.cache — no API call
  • After TTL expires, next request fetches fresh data

This significantly reduces API load for high-traffic applications. Choose a TTL that balances freshness with performance — 30 seconds is a good starting point.

Bulk Flag Evaluation (Prefetch)

For optimal performance, prefetch all flags for a user in a single API call. This is especially useful when your page checks multiple flags:

# config/application.rb (required)
config.middleware.use Subflag::Rails::RequestCache::Middleware
class ApplicationController < ActionController::Base
  before_action :prefetch_feature_flags

  private

  def prefetch_feature_flags
    subflag_prefetch  # Fetches all flags for current_user in one API call
  end
end

Now all subsequent flag lookups use the cache — no additional API calls:

# In your controller/view - all lookups are instant (cache hits)
subflag_enabled?(:new_checkout)           # Cache hit
subflag_value(:max_projects, default: 3)  # Cache hit
subflag_value(:headline, default: "Hi")   # Cache hit

How It Works

  1. Single API call: subflag_prefetch calls /sdk/evaluate-all to fetch all flags
  2. Per-request cache: Results are stored in RequestCache for the duration of the request
  3. Zero-latency lookups: Subsequent subflag_enabled? and subflag_value calls read from cache

Prefetch Without current_user

# No user context
subflag_prefetch(nil)

# With specific user
subflag_prefetch(admin_user)

# With additional context
subflag_prefetch(current_user, context: { device: "mobile" })

Direct API

You can also use the module method directly:

Subflag.prefetch_flags(user: current_user)
# or
Subflag::Rails.prefetch_flags(user: current_user)

Configuration

Subflag::Rails.configure do |config|
  # Backend: :subflag (cloud), :active_record (self-hosted), :memory (testing)
  config.backend = :subflag

  # API key - required for :subflag backend
  config.api_key = "sdk-production-..."

  # API URL (default: https://api.subflag.com)
  config.api_url = "https://api.subflag.com"

  # Cross-request caching via Rails.cache (optional, :subflag backend only)
  # When set, prefetched flags are cached for this duration
  config.cache_ttl = 30.seconds

  # Logging
  config.logging_enabled = Rails.env.development?
  config.log_level = :debug  # :debug, :info, :warn

  # User context - works with all backends
  config.user_context do |user|
    { targeting_key: user.id.to_s, plan: user.plan }
  end
end

ActiveRecord Flag Model

Note: This section only applies when using backend: :active_record. For Subflag Cloud, manage flags in the dashboard.

Flags are stored in the subflag_flags table:

Column Type Description
key string Flag name (lowercase, dashes, e.g., new-checkout)
value text Default value (what everyone gets)
value_type string Type: boolean, string, integer, float, object
enabled boolean Whether the flag is active (default: true)
description text Optional description
targeting_rules json Optional rules for showing different values to different users
# Create flags
Subflag::Rails::Flag.create!(key: "max-projects", value: "100", value_type: "integer")

# Query flags
Subflag::Rails::Flag.enabled.find_each { |f| puts "#{f.key}: #{f.typed_value}" }

# Disable a flag
Subflag::Rails::Flag.find_by(key: "new-checkout")&.update!(enabled: false)

Targeting Rules (ActiveRecord)

Show different flag values to different users based on their attributes. Perfect for internal testing before wider rollout.

Tip: Use the Admin UI to manage targeting rules visually instead of editing JSON.

First, configure user context:

# config/initializers/subflag.rb
Subflag::Rails.configure do |config|
  config.backend = :active_record

  config.user_context do |user|
    {
      targeting_key: user.id.to_s,
      email: user.email,
      role: user.role,           # e.g., "admin", "developer", "qa"
      plan: user.plan            # e.g., "free", "pro", "enterprise"
    }
  end
end

Create flags with targeting rules:

# Internal team sees new feature, everyone else sees old
Subflag::Rails::Flag.create!(
  key: "new-dashboard",
  value: "false",                    # Default: everyone gets false
  value_type: "boolean",
  targeting_rules: [
    {
      "value" => "true",             # Internal team gets true
      "conditions" => {
        "type" => "OR",
        "conditions" => [
          { "attribute" => "email", "operator" => "ENDS_WITH", "value" => "@yourcompany.com" },
          { "attribute" => "role", "operator" => "IN", "value" => ["admin", "developer", "qa"] }
        ]
      }
    }
  ]
)

Progressive rollout with multiple rules (first match wins):

Subflag::Rails::Flag.create!(
  key: "max-projects",
  value: "5",                        # Default: everyone gets 5
  value_type: "integer",
  targeting_rules: [
    { "value" => "1000", "conditions" => { "type" => "AND", "conditions" => [{ "attribute" => "role", "operator" => "EQUALS", "value" => "admin" }] } },
    { "value" => "100", "conditions" => { "type" => "AND", "conditions" => [{ "attribute" => "email", "operator" => "ENDS_WITH", "value" => "@yourcompany.com" }] } },
    { "value" => "25", "conditions" => { "type" => "AND", "conditions" => [{ "attribute" => "plan", "operator" => "EQUALS", "value" => "pro" }] } }
  ]
)

Supported operators:

Operator Example Description
EQUALS { "attribute" => "role", "operator" => "EQUALS", "value" => "admin" } Exact match
NOT_EQUALS { "attribute" => "env", "operator" => "NOT_EQUALS", "value" => "prod" } Not equal
IN { "attribute" => "role", "operator" => "IN", "value" => ["admin", "qa"] } Value in list
NOT_IN { "attribute" => "country", "operator" => "NOT_IN", "value" => ["RU", "CN"] } Value not in list
CONTAINS { "attribute" => "email", "operator" => "CONTAINS", "value" => "test" } String contains
NOT_CONTAINS { "attribute" => "email", "operator" => "NOT_CONTAINS", "value" => "spam" } String doesn't contain
STARTS_WITH { "attribute" => "user_id", "operator" => "STARTS_WITH", "value" => "test-" } String prefix
ENDS_WITH { "attribute" => "email", "operator" => "ENDS_WITH", "value" => "@company.com" } String suffix
GREATER_THAN { "attribute" => "age", "operator" => "GREATER_THAN", "value" => 18 } Numeric greater than
LESS_THAN { "attribute" => "items", "operator" => "LESS_THAN", "value" => 100 } Numeric less than
GREATER_THAN_OR_EQUAL { "attribute" => "score", "operator" => "GREATER_THAN_OR_EQUAL", "value" => 80 } Numeric greater or equal
LESS_THAN_OR_EQUAL { "attribute" => "score", "operator" => "LESS_THAN_OR_EQUAL", "value" => 50 } Numeric less or equal
MATCHES { "attribute" => "email", "operator" => "MATCHES", "value" => ".*@company\\.com$" } Regex match

Combining conditions:

# OR: any condition matches
{
  "type" => "OR",
  "conditions" => [
    { "attribute" => "email", "operator" => "ENDS_WITH", "value" => "@company.com" },
    { "attribute" => "role", "operator" => "EQUALS", "value" => "admin" }
  ]
}

# AND: all conditions must match
{
  "type" => "AND",
  "conditions" => [
    { "attribute" => "plan", "operator" => "EQUALS", "value" => "enterprise" },
    { "attribute" => "country", "operator" => "IN", "value" => ["US", "CA"] }
  ]
}

Percentage rollouts:

Gradually roll out features to a percentage of users using the Admin UI:

  1. Create or edit a flag at /subflag
  2. Add a targeting rule with a percentage (0-100)
  3. Optionally combine with conditions (e.g., "50% of pro users")

Assignment is deterministic — the same user always gets the same result for the same flag.

Note: Percentage rollouts require targeting_key in your user context (typically the user ID).

How evaluation works:

  1. Flag disabled? → return code default
  2. For each rule (in order): if context matches → return rule's value
  3. No rules matched? → return flag's default value

This lets you progressively widen access by adding rules, without changing existing ones.

Admin UI (ActiveRecord)

Mount the admin UI to manage flags and targeting rules visually:

# config/routes.rb
Rails.application.routes.draw do
  mount Subflag::Rails::Engine => "/subflag"
  # ... your other routes
end

Subflag Admin UI

The admin UI provides:

  • List, create, edit, and delete flags
  • Toggle flags enabled/disabled
  • Visual targeting rule builder (no JSON editing)
  • Test rules against sample contexts

Secure the admin UI:

# config/initializers/subflag.rb
Subflag::Rails.configure do |config|
  config.backend = :active_record

  # Require authentication for admin UI
  config.admin_auth do
    redirect_to main_app.root_path unless current_user&.admin?
  end
end

Visit /subflag in your browser to access the admin UI.

Testing

Stub flags in your tests:

# spec/rails_helper.rb (RSpec)
require "subflag/rails/test_helpers"
RSpec.configure do |config|
  config.include Subflag::Rails::TestHelpers
end

# test/test_helper.rb (Minitest)
require "subflag/rails/test_helpers"
class ActiveSupport::TestCase
  include Subflag::Rails::TestHelpers
end
# In your specs/tests
it "shows new checkout when enabled" do
  stub_subflag(:new_checkout, true)
  stub_subflag(:max_projects, 100)

  visit checkout_path
  expect(page).to have_content("New Checkout")
end

# Stub multiple at once
stub_subflags(
  new_checkout: true,
  max_projects: 100,
  headline: "Welcome!"
)

Documentation

License

MIT