PhoenixFlags

Database-backed, cached, cluster-aware system configuration for Phoenix.

PhoenixFlags gives you a system settings page for your Phoenix app — typed configuration values stored in PostgreSQL, cached in :persistent_term for zero-cost reads, with automatic cluster replication and compile-time validated flag declarations.

Why PhoenixFlags?

Most Phoenix apps eventually need runtime-configurable settings that aren't environment variables — things like "enable the benefits integration" or "set the default fee percentage". The usual approaches each have drawbacks:

ApproachProblem
Application.get_envRequires a deploy to change. No UI.
Environment variablesSame — requires restart. No validation.
Feature flag service (LaunchDarkly, FunWithFlags)External dependency. Often boolean-only. Overkill for system settings.
Ad-hoc database tableNo caching, no cluster sync, no type system, rebuilt per project.

PhoenixFlags occupies the middle ground: typed values with validation, zero-cost cached reads, cluster-aware writes, declarative flag definitions with compile-time checks, and a simple API for building admin UIs. One dependency, no external services.

Features

Installation

Add phoenix_flags to your dependencies in mix.exs:

def deps do
[
{:phoenix_flags, "~> 0.6"}
]
end

Quick Start

1. Define your flags module

defmodule MyApp.SystemConfig do
use PhoenixFlags,
otp_app: :my_app,
repo: MyApp.Repo
flag "enable_benefits",
type: :boolean,
default: "false",
category: "integrations",
label: "Enable Benefits",
description: "When enabled, the Benefits integration is active."
flag "default_fee_percentage",
type: :percentage,
default: "5.0",
category: "fees",
label: "Default fee %",
description: "Applied to all new transactions."
flag "max_retries",
type: :integer,
default: "3",
category: "system",
label: "Max retries",
description: "Maximum retry attempts for failed jobs."
# Convenience helpers
def benefits_enabled?, do: get("enable_benefits", false)
end

Flags are validated at compile time. A typo in the type or an invalid default will raise PhoenixFlags.Error during mix compile — not at runtime.

2. Create the migration

mix ecto.gen.migration create_system_flags
defmodule MyApp.Repo.Migrations.CreateSystemFlags do
use Ecto.Migration
def up, do: PhoenixFlags.Migration.up()
def down, do: PhoenixFlags.Migration.down(version: 1)
end

That's it — one migration for the table. Flag entries are seeded automatically on application startup from your flag/2 declarations.

3. Add to your supervision tree

# lib/my_app/application.ex
children = [
MyApp.Repo,
MyApp.SystemConfig,
# ...
]

4. Configure test environment

# config/test.exs
config :my_app, MyApp.SystemConfig, cache_enabled: false

5. Run the migration

mix ecto.migrate

On first boot, PhoenixFlags will seed your declared flags into the database.

Reading Values

MyApp.SystemConfig.get("enable_benefits")
#=> false (cast from "false" to boolean)
MyApp.SystemConfig.get("default_fee_percentage")
#=> #Decimal<5.0>
MyApp.SystemConfig.get("nonexistent", "fallback")
#=> "fallback"
# Via convenience helper
MyApp.SystemConfig.benefits_enabled?()
#=> false

Reads go directly to :persistent_term — no GenServer call, no ETS copy, no database query. This is as fast as reading a module attribute.

Updating Values

MyApp.SystemConfig.update_entry("enable_benefits", %{"value" => "true"})
#=> {:ok, %PhoenixFlags.Entry{key: "enable_benefits", value: "true", ...}}

An update triggers three things in sequence:

  1. Database write (source of truth)
  2. Local :persistent_term cache reload
  3. :reload message sent to all connected cluster nodes

Invalid values are rejected with changeset errors:

MyApp.SystemConfig.update_entry("enable_benefits", %{"value" => "maybe"})
#=> {:error, #Ecto.Changeset<errors: [value: {"must be true or false", []}]>}
MyApp.SystemConfig.update_entry("default_fee_percentage", %{"value" => "150"})
#=> {:error, #Ecto.Changeset<errors: [value: {"must be between 0 and 100", []}]>}

Declarative Flags

The flag/2 macro is the recommended way to define flags. It provides:

Compile-Time Validation

# This raises PhoenixFlags.Error at compile time:
flag "bad_flag", type: :invalid_type, default: "x"
# So does this:
flag "bad_bool", type: :boolean, default: "yes" # must be "true" or "false"
# And this:
flag "bad_pct", type: :percentage, default: "150" # must be 0-100

Automatic Seeding

On GenServer startup, PhoenixFlags syncs the database with your declarations:

This means you never write seed migrations. Add a flag, deploy, done.

Supported Types

TypeElixir atomStored asCast toValidation
String:string"hello""hello"none
Boolean:boolean"true"true"true" or "false"
Integer:integer"42"42must parse as integer
Decimal:decimal"3000.50"Decimal.new("3000.50")must parse as decimal
Percentage:percentage"50"Decimal.new("50")0..100
Select:select"ses""ses"must be one of the declared :options
Secret:secretciphertext"plaintext"none (see Secrets)
Variant:variant"a=50,b=50"%Variant{}weights total 100, declared (see A/B Testing)

All values are stored as strings. Casting happens once when the cache is loaded, not on every read.

:select membership is enforced on writes as well as on the declared default, so get/2 can only ever return one of the declared option values. That matters because the rendered <select> is not a validation boundary — LiveView event params come from the client — and code that pattern matches on the known options would otherwise crash on an unexpected value.

Secrets

For credentials (API keys, webhook signing secrets, etc.) use type: :secret. PhoenixFlags encrypts the value at rest using a host-supplied encryptor, masks it in the admin dashboard, and redacts it in the audit log.

1. Write an encryptor module

Any module that exports encrypt/1 and decrypt/1 on binaries will do — PhoenixFlags is crypto-agnostic so you pick the cipher and manage the key. A minimal AES-256-GCM version:

defmodule MyApp.FlagEncryptor do
@aad "phoenix_flags_v1"
def encrypt(plaintext) do
iv = :crypto.strong_rand_bytes(12)
{ct, tag} = :crypto.crypto_one_time_aead(:aes_256_gcm, key(), iv, plaintext, @aad, true)
Base.encode64(iv <> tag <> ct)
end
def decrypt(blob) do
<<iv::12-binary, tag::16-binary, ct::binary>> = Base.decode64!(blob)
:crypto.crypto_one_time_aead(:aes_256_gcm, key(), iv, ct, @aad, tag, false)
end
defp key, do: Application.fetch_env!(:my_app, __MODULE__)[:key]
end

2. Wire it into your config module

defmodule MyApp.SystemConfig do
use PhoenixFlags,
otp_app: :my_app,
repo: MyApp.Repo,
encryptor: MyApp.FlagEncryptor
flag "anthropic_api_key",
type: :secret,
category: "ai",
label: "Anthropic API key"
end

Declaring any :secret flag requires an :encryptor option. Omitting it raises a PhoenixFlags.Error at compile time; misconfiguring it (module missing encrypt/1 or decrypt/1) raises on boot.

What the admin UI and audit log show

Caveats

A/B Testing

A :variant flag resolves to a different value per caller, chosen by a consistent hash of an identity you supply. The same identity always gets the same variant — on every node, across restarts and deploys — so a user sees a stable experience and the results stay analysable.

defmodule MyApp.SystemConfig do
use PhoenixFlags, otp_app: :my_app, repo: MyApp.Repo
flag "checkout_flow",
type: :variant,
category: "experiments",
label: "Checkout flow experiment",
variants: [
{"Control", "control", 90},
{"New flow", "new_flow", 10}
]
end
MyApp.SystemConfig.variant("checkout_flow", user.id)
#=> "control"
MyApp.SystemConfig.variant("checkout_flow", user.id)
#=> "control" # always, for this user
MyApp.SystemConfig.get("checkout_flow")
#=> ** (PhoenixFlags.Error) "checkout_flow" is a :variant flag and has no single
# value. Read it with variant("checkout_flow", identity) instead of get/2.

Weights are whole numbers that must total 100. They are stored as the flag's value ("control=90,new_flow=10") and so can be changed at runtime from the dashboard — a rollout goes 5% → 15% → 40% → 100% with no deploy.

Gradual rollouts are sticky

Buckets are cumulative in declaration order, so growing a variant at the expense of the next one moves only the boundary between them. Going from control=90,new_flow=10 to control=80,new_flow=20 moves the 80–90 band and leaves everyone else exactly where they were — nobody already seeing new_flow is moved back to control.

That property does not survive reordering the :variants declaration, changing :seed, or a :ttl rollover. Any of those reshuffles the population.

Assignment lifetime (:ttl)

By default an assignment is permanent. Set :ttl in milliseconds to re-roll each caller once per window:

flag "banner_copy",
type: :variant,
ttl: :timer.hours(24), # nil (default) = never expires
variants: [{"A", "a", 50}, {"B", "b", 50}]

Windows are offset per identity, so the population does not all flip at the same instant. This is stateless — no rows are stored and no database call is made; the window is simply folded into the hash.

Independence and seeds

The flag key is part of the hash input, so two concurrent experiments do not correlate: a user in control for one is not systematically in control for the other. Pass seed: "some-string" to re-randomise everyone — useful when restarting an experiment on the same flag.

Assignment uses SHA-256 rather than :erlang.phash2/2, which is not guaranteed stable across OTP major versions; an OTP upgrade must not silently reshuffle a running experiment.

Tracking exposures

variant/3 emits nothing by default, to keep the read path free. Pass telemetry: true to emit [:phoenix_flags, :variant, :assigned]:

:telemetry.attach("ab-exposures", [:phoenix_flags, :variant, :assigned], fn _e, _m, meta, _c ->
MyApp.Analytics.track(meta.identity, meta.flag, meta.variant)
end, nil)
MyApp.SystemConfig.variant("checkout_flow", user.id, telemetry: true)

Testing

MyApp.SystemConfig.Test.stub("checkout_flow", "new_flow")
# every identity now resolves to "new_flow"

A missing identity would put every caller in the same bucket, so variant/3 raises on nil (or anything that is not a non-empty string or an integer) rather than bucketing silently.

Targeting

An A/B split is random by design. Targeting is the opposite: force a specific value for a specific caller — onboard a beta customer, unblock one account, raise a limit for one tenant. Rules live in the database and are added from the dashboard, so none of that needs a deploy.

1. Provide a context

Set it once where you already have the current user:

# lib/my_app_web/plugs/flag_context.ex
defmodule MyAppWeb.Plugs.FlagContext do
def init(opts), do: opts
def call(conn, _opts) do
case conn.assigns[:current_user] do
nil -> conn
user ->
PhoenixFlags.put_context(user_id: user.id, company_id: user.company_id)
conn
end
end
end

Every read in that process is then targeted, with no change to the call sites:

MyApp.SystemConfig.get("enable_benefits", false)
MyApp.SystemConfig.variant("checkout_flow", user.id)

Or pass one explicitly, which wins over the process context:

MyApp.SystemConfig.get("enable_benefits", false, context: %{company_id: 999})

2. Add a rule

From the dashboard's edit dialog, or in code:

MyApp.SystemConfig.put_target("enable_benefits",
conditions: [[attribute: :company_id, operator: :in, values: [123, 456]]],
value: "true"
)
MyApp.SystemConfig.targets("enable_benefits")
MyApp.SystemConfig.delete_target(target_id)

Conditions within a rule are ANDed; rules are checked in the order they were added and the first match wins.

OperatorMatches when
:inthe context value is any of values
:not_inthe context value is none of values
:eqthe context value equals the first of values
:starts_withthe context value starts with any of values
# ANDed conditions
put_target("enable_benefits",
conditions: [
[attribute: :plan, operator: :eq, values: ["enterprise"]],
[attribute: :region, operator: :in, values: ["eu", "uk"]]
],
value: "true"
)

What wins

For every read, in order:

  1. A test stub (MyModule.Test.stub/2), in the test environment
  2. A matching targeting rule
  3. The stored value, or for a :variant flag the weighted split

So a rule overrides an A/B split — pinning a customer to one arm is the point.

Things worth knowing

Architecture

┌─────────────────────┐
│ Your Application │
│ │
get("key") ────▶│ :persistent_term │◀──── zero-copy reads
│ {values, entries} │ (no process call)
└─────────┬───────────┘
update_entry() │ GenServer.call
┌─────────────────────┐
│ PhoenixFlags.Server│
│ │
│ 1. Repo.update() │
│ 2. load_cache() │
│ 3. notify_peers() │──────▶ Node.list()
└─────────┬───────────┘ send(:reload)
┌─────────────────────┐
│ PostgreSQL │
│ system_flags table │
└─────────────────────┘

Why :persistent_term?

Cluster Replication

After a write, the GenServer sends :reload directly to its named counterpart on all connected nodes via send({instance_name, node}, :reload). No PubSub dependency, no Phoenix channels — just Erlang distribution.

A node that misses a notification (network partition, restart in progress, full send buffer) is not stale forever: every instance also reloads its cache from the database on a jittered interval (refresh_interval, default 60 seconds). That interval is the upper bound on cross-node staleness. Set refresh_interval: false to disable the periodic refresh, or lower it if you need tighter convergence.

One Config Module per Repo

All flags live in a single system_flags table, and each config module removes keys it doesn't declare at startup. Two config modules with different flag declarations sharing one repo would therefore delete each other's rows — the server detects this at boot and refuses to start. Run one PhoenixFlags module per repo (multiple nodes running the same module is, of course, fine).

Testing

In the :test environment, use PhoenixFlags generates a Test submodule with two helpers:

# Auto-generated: MyApp.SystemConfig.Test
MyApp.SystemConfig.Test.stub("key", value) # process dictionary
MyApp.SystemConfig.Test.insert_entry("key", value) # database

Unit Tests (Same Process)

Use stub/2 for tests where the config is read in the same process. No database, no race conditions, safe for async: true:

test "grants access when benefits enabled" do
MyApp.SystemConfig.Test.stub("enable_benefits", true)
assert MyApp.SystemConfig.benefits_enabled?()
assert MyApp.Access.can_view_benefits?(user)
end
test "denies access when benefits disabled" do
# No override needed — default is false
refute MyApp.SystemConfig.benefits_enabled?()
end

LiveView / Integration Tests (Cross-Process)

Use insert_entry/3 when the config is read in a different process (LiveView, channel, async task). The Ecto sandbox in shared mode makes the row visible to all processes in the test:

test "shows benefits section when enabled", %{conn: conn} do
MyApp.SystemConfig.Test.insert_entry("enable_benefits", true)
{:ok, view, _html} = live(conn, ~p"/dashboard")
assert has_element?(view, "#benefits-section")
end

Why Two Helpers?

HelperMechanismVisible toUse when
stub/2Process dictionarySame process onlyUnit tests, context tests
insert_entry/3Database (Ecto sandbox)All processes in testLiveView, integration tests

stub/2 is faster and simpler. Use insert_entry/3 only when you need cross-process visibility.

Versioned Migrations

PhoenixFlags uses Oban's migration versioning pattern. The schema version is stored in the system_flags_meta table (schema V3+; older versions stored it as a PostgreSQL comment on the system_flags table, and migrated_version/1 still reads the comment on databases that haven't run the V3 migration yet).

When upgrading to a new package version with schema changes, generate a new migration:

defmodule MyApp.Repo.Migrations.UpgradeSystemFlagsToV2 do
use Ecto.Migration
def up, do: PhoenixFlags.Migration.up(version: 2)
def down, do: PhoenixFlags.Migration.down(version: 2)
end

Check the current version:

PhoenixFlags.Migration.migrated_version()
#=> 1

Admin Dashboard

PhoenixFlags ships a self-contained LiveView dashboard with its own CSS and layout. Mount it with a single router line — no dependency on your app's stylesheets or layout system.

Mounting the Dashboard

defmodule MyAppWeb.Router do
use Phoenix.Router
import PhoenixFlags.Router
scope "/admin" do
pipe_through [:browser, :require_admin]
flags_dashboard "/flags",
config: MyApp.SystemConfig,
on_mount: [{MyAppWeb.AdminAuth, :ensure_authenticated}]
end
end

The dashboard has no built-in authentication. It renders and edits every flag for anyone who can reach the route — including :secret values, which are write-only in the UI but whose changes are still actor-attributed in the audit log. Protect it yourself, at both layers: a router pipeline for the initial HTTP request (pipe_through) and an :on_mount hook for the LiveView connection. A pipeline alone does not guard the WebSocket mount.

Visit /admin/flags to see all your flags grouped by category with:

The dialog closes on Save, Cancel, the ×, Escape, or a click on the backdrop. Keyboard focus moves into the dialog when it opens.

Dashboard Options

flags_dashboard "/flags",
config: MyApp.SystemConfig, # required
on_mount: [{MyAppWeb.AdminAuth, :ensure_authenticated}] # auth hooks
OptionDescription
:config (required)The module that use PhoenixFlags
:on_mountList of on_mount hooks for the live session (e.g. authentication)
:live_socket_pathDefaults to "/live"
:app_jsPath to the app's JS bundle. Defaults to "/assets/js/app.js"

How It Works

Custom UI

If you want full control, build your own LiveView using the data API:

MyApp.SystemConfig.all_grouped()
#=> [{"integrations", [%Entry{key: "enable_benefits", ...}]}, ...]
MyApp.SystemConfig.update_entry("enable_benefits", %{"value" => "true"})
#=> {:ok, %Entry{...}}

Audit Log

PhoenixFlags includes an opt-in audit log that records every flag value change.

Setup

Enable audit logging in your config module:

defmodule MyApp.SystemConfig do
use PhoenixFlags,
otp_app: :my_app,
repo: MyApp.Repo,
audit: true,
actor_fn: &MyApp.SystemConfig.current_user/1
# Extract the actor from the LiveView socket or Plug conn
def current_user(%Phoenix.LiveView.Socket{} = socket) do
socket.assigns.current_admin.email
end
def current_user(_), do: "system"
# ... flag declarations
end

Then generate a migration to add the audit table:

defmodule MyApp.Repo.Migrations.UpgradeSystemFlagsV2 do
use Ecto.Migration
def up, do: PhoenixFlags.Migration.up(version: 2)
def down, do: PhoenixFlags.Migration.down(version: 2)
end

How It Works

Querying the Audit Log

MyApp.SystemConfig.audit_log()
#=> [%PhoenixFlags.AuditLog{key: "enable_benefits", old_value: "false", new_value: "true", actor: "admin@example.com", ...}, ...]
MyApp.SystemConfig.audit_log("enable_benefits")
#=> [%PhoenixFlags.AuditLog{...}, ...] # filtered by key

Passing Actor from Code

When updating flags outside the dashboard, pass the actor via opts:

MyApp.SystemConfig.update_entry("enable_benefits", %{"value" => "true"},
actor: "deploy@ci"
)

Comparison

Application envFunWithFlagsPhoenixFlags
Runtime changesNo (deploy required)YesYes
Typed valuesNoBoolean only8 types + validation
A/B testingNoNoWeighted variants, consistent hash
Per-user targetingNoActor gates (code)Runtime rules on any attribute
CachingN/A (in-memory)ETS:persistent_term (zero-copy)
Cluster syncNoRedis/Ecto pollingDirect node messaging
Admin UIN/ANoBuilt-in dashboard, one router line
External depsNoneRedis (optional)None
Compile-time checksNoNoYes (flag/2 macro)
Auto-seedingNoNoYes (on startup)

API Reference

Module API (generated by use PhoenixFlags)

FunctionDescription
get(key, default \\ nil)Read a cached value, cast to native type
update_entry(key, attrs, opts \\ [])Update a value, sync cache + cluster. Accepts :timeout and :actor.
all_grouped()All entries grouped by category (for admin UI)
flags()List of declared PhoenixFlags.Flag structs
select_options(key){label, value} options for a :select flag, or []
variant(key, identity, opts \\ [])Variant assigned to identity. Accepts :default, :telemetry.
variants(key)Declared {label, value, weight} variants for a :variant flag, or []
targets(key)Targeting rules for a flag, in evaluation order
put_target(key, attrs)Add a targeting rule (:conditions, :value, optional :position)
delete_target(id)Delete a targeting rule
audit_log()All audit entries, newest first (requires audit: true)
audit_log(key)Audit entries for a specific key, newest first

Test API (generated in :test env as MyModule.Test)

FunctionDescription
stub(key, value)Process-scoped override, no DB
insert_entry(key, value, opts)DB insert/upsert for cross-process tests

Router API

MacroDescription
flags_dashboard(path, opts)Mount the embedded dashboard LiveView

Migration API

FunctionDescription
PhoenixFlags.Migration.up(opts)Run migrations up to version
PhoenixFlags.Migration.down(opts)Roll back migrations to version
PhoenixFlags.Migration.migrated_version()Current schema version

Context API

FunctionDescription
PhoenixFlags.put_context(attrs)Replace the current process's targeting context
PhoenixFlags.merge_context(attrs)Merge into it
PhoenixFlags.context()Read it
PhoenixFlags.clear_context()Clear it

Development

Everything below needs a reachable PostgreSQL. Override the connection with the POSTGRES_USER, POSTGRES_PASSWORD and DB_HOST environment variables.

Running the dashboard locally

mix run dev.exs

This boots a real Phoenix + LiveView server on http://localhost:4005, opens your browser, and seeds a sample flag of every type — including two :variant experiments, so you can exercise the weights editor. It uses its own phoenix_flags_dev database, created automatically, and builds its JavaScript inline from the phoenix and phoenix_live_view dependencies, so there is no asset pipeline to set up.

Restart the server after changing code. Editing priv/static/app.css is picked up on restart too — the dashboard's asset module declares the stylesheet as an @external_resource, so changing it triggers recompilation.

Running the tests

mix test creates and migrates phoenix_flags_test itself.

mix test # everything
mix test test/phoenix_flags/variant_test.exs # A/B assignment properties
mix test test/phoenix_flags/ui # the LiveView dashboard

The dashboard is covered by Phoenix.LiveViewTest, so its behaviour — opening the dialog, saving, validation errors, forged payloads — is tested without a browser.

To match CI exactly:

mix format --check-formatted
mix deps.unlock --check-unused
mix compile --warnings-as-errors
mix credo
mix hex.audit
MIX_ENV=dev mix docs

CI also runs the suite on Elixir 1.16/OTP 26, 1.18/OTP 27 and 1.20/OTP 28, which is what verifies the declared elixir: "~> 1.16" floor.

Poking at the API interactively

dev.exs ends in Process.sleep(:infinity), so iex -S mix run dev.exs never reaches a prompt. Use a throwaway script instead:

# probe.exs → mix run probe.exs
Code.require_file("bench/bench_helper.exs")
alias PhoenixFlags.{Config, Server}
defmodule Probe do
use PhoenixFlags, otp_app: :phoenix_flags, repo: PhoenixFlags.TestRepo
flag "exp",
type: :variant,
category: "e",
label: "Exp",
variants: [{"Control", "control", 90}, {"New", "new", 10}]
end
{:ok, _} =
Server.start_link(%Config{
otp_app: :phoenix_flags,
repo: PhoenixFlags.TestRepo,
name: Probe,
cache_enabled: true
})
IO.inspect(for index <- 1..10, do: Probe.variant("exp", "user-#{index}"))
Probe.update_entry("exp", %{"value" => "control=20,new=80"})
IO.inspect(for index <- 1..10, do: Probe.variant("exp", "user-#{index}"))

bench/bench_helper.exs points at its own phoenix_flags_bench database, created and migrated on first use. Scripts that require it therefore cannot disturb phoenix_flags_test — which matters because they run outside the Ecto sandbox, so their writes commit.

Trying it in your own app

Point at a local checkout instead of Hex:

{:phoenix_flags, path: "../phoenix_flags"}

Then mix deps.get, generate the migration, and mount the dashboard as above.

Benchmarks

mix run bench/phoenix_flags_bench.exs

See docs/benchmarks.md for recorded results.

License

MIT License. See LICENSE for details.