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:
| Approach | Problem |
|---|---|
Application.get_env |
Requires a deploy to change. No UI. |
| Environment variables | Same — requires restart. No validation. |
| Feature flag service (LaunchDarkly, FunWithFlags) | External dependency. Often boolean-only. Overkill for system settings. |
| Ad-hoc database table | No 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
- Zero-cost reads —
:persistent_termcache, no process calls, no ETS copies - Typed values — boolean, integer, decimal, percentage, select, string, secret, variant
- A/B testing — weighted variants assigned by a consistent hash of an identity, editable at runtime for gradual rollouts
- Targeting rules — force a value for one user, company, or any attribute you pass, added from the dashboard without a deploy
- Compile-time validation —
flag/2macro validates keys, types, and defaults at compile time - Declarative flags — define flags in code, auto-seeded on startup, safe across a rolling deploy
- Cluster-aware — writes notify all connected nodes immediately; a periodic refresh heals nodes that missed a notification (partition, restart)
- Test-friendly — process dictionary overrides (no DB, no races) + Ecto sandbox helpers
- Embedded admin dashboard — LiveView UI that renders inside your app's layout, mount with one router line
- Versioned migrations — Oban-style migration versioning for schema upgrades
- Metadata sync — label, description, category changes deploy automatically; runtime values preserved
Installation
Add phoenix_flags to your dependencies in mix.exs:
def deps do
[
{:phoenix_flags, "~> 0.11"}
]
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:
- Database write (source of truth)
- Local
:persistent_termcache reload :reloadmessage 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:
- New flags are inserted with their declared defaults
- Removed flags (no longer in code) are kept, logged, and marked in the
dashboard — during a rolling deploy the old and new releases disagree about
which flags exist, and deleting would lose a value an admin had just set.
MyApp.SystemConfig.prune_undeclared()deletes them, with their targeting rules, once no node declares them - Changed metadata (label, description, category) is updated automatically
- Runtime values are preserved — if an admin changed a value via the UI, it stays
- Type changes keep a value the new type accepts —
"5"survives:stringto:integer;"true"would be invalid as an integer, so it is reset to the declared default. A:secreton either side always resets - A database that is down at startup does not stop your app — reads return defaults while the seed and load are retried, with backoff, until they succeed
This means you never write seed migrations. Add a flag, deploy, done.
Supported Types
| Type | Elixir atom | Stored as | Cast to | Validation |
|---|---|---|---|---|
| String | :string |
"hello" |
"hello" |
none |
| Boolean | :boolean |
"true" |
true |
"true" or "false" |
| Integer | :integer |
"42" |
42 |
must 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 | :secret |
ciphertext | "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
- Dashboard: "Set" / "Not set" with an Edit button. Editing renders a blank password input — operators type a new value, and saving it blank keeps the stored one. A separate Clear button empties it. The ciphertext is never sent to the DOM.
- Audit log (if enabled):
old_valueandnew_valueare stored as"[redacted]"when non-empty, and""when the secret is cleared. Rotations are still auditable (who + when) without leaking plaintext.
Caveats
- PhoenixFlags does no crypto itself — your encryptor is the entire trust boundary. Key management, rotation, and algorithm choice are on you.
get/2returns the decrypted plaintext from the:persistent_termcache; treat it with the same care as an env var.all_grouped/0returns entries with the stored ciphertext inentry.value— safe to render in bulk without special handling.
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.
| Operator | Matches when |
|---|---|
:in |
the context value is any of values |
:not_in |
the context value is none of values |
:eq |
the context value equals the first of values |
:starts_with |
the 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:
- A test stub (
MyModule.Test.stub/2), in the test environment - A matching targeting rule
- The stored value, or for a
:variantflag the weighted split
So a rule overrides an A/B split — pinning a customer to one arm is the point.
Things worth knowing
- Everything compares as strings. A context of
%{company_id: 123}matches a rule value of"123"; every flag value is stored as a string and rule values follow suit. Attribute keys too, so:company_idand"company_id"are the same attribute. If a rule never seems to fire, check this first. - A missing attribute never matches, including for
:not_in— "everyone except these" will not sweep in callers you know nothing about. - A rule value is validated against the flag's type, so a
:booleanrule must be"true"/"false"and a:variantrule must name a declared variant. :secretflags cannot be targeted. The rule value would be stored as plaintext, defeating the encryptor.- The context is per-process and is not inherited.
Task.async/1starts with an empty one; passcontext: PhoenixFlags.context()into the task, or set it again inside. - Cost when unused is a single
:persistent_termread. A flag with no rules never even reads the context.
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?
- Zero-copy: Unlike ETS,
:persistent_termvalues are shared across all processes without copying. A map with 100 entries costs zero per-read overhead. - No process bottleneck: Reads bypass the GenServer entirely — they're a direct memory lookup.
- Trade-off: Writes trigger a global GC of
:persistent_termvalues. This is fine for system config that changes rarely (minutes/hours), but would be bad for high-frequency writes.
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. Two config modules with
different flag declarations sharing one repo would overwrite each other's
metadata, and prune_undeclared/0 on one would delete the other's flags — 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?
| Helper | Mechanism | Visible to | Use when |
|---|---|---|---|
stub/2 |
Process dictionary | The test, and Tasks it starts | Unit tests, context tests |
insert_entry/3 |
Database (Ecto sandbox) | All processes in test | LiveView, integration tests |
stub/2 is faster and simpler. Use insert_entry/3 only when you need
cross-process visibility. insert_entry/3 takes the type from the flag's declaration
and encrypts a :secret with your encryptor, as a real save would.
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 page refreshes itself every few seconds, so a change made by another admin, or on another node, shows up without a reload.
The dashboard has no built-in authentication. It renders and edits every flag for anyone who can reach the route — including
:secretvalues, 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_mounthook 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:
- Toggle switches for booleans, which save on click
- An Edit dialog for every other type, opened from the row
- Percentage bars for
:variantflags, with a weights editor and a live total Set/Not setfor:secretflags, edited through a blank password field — saving it blank keeps the stored secret; Clear empties it- Validation errors shown in the dialog, which stays open until the value is valid
- Dark mode support (via
prefers-color-scheme)
The dialog closes on Save, Cancel, the ×, Escape, or a click on the backdrop. Keyboard focus moves into the dialog when it opens, stays inside it, and returns to the Edit button when it closes.
Dashboard Options
flags_dashboard "/flags",
config: MyApp.SystemConfig, # required
on_mount: [{MyAppWeb.AdminAuth, :ensure_authenticated}], # auth hooks
app_js_type: "module", # if your bundle is ESM
favicon: "/favicon.svg", # defaults to no request
csp_nonce_assign_key: :csp_nonce # your CSP nonce assign
If your bundle is ESM, you must pass
app_js_type: "module". esbuild's--format=esm, and any bundle usingimport()for code splitting, produce one. Loaded as a classic script it throwsimport declarations may only appear at top level of a modulebefore defining anything — so the LiveSocket never connects and every toggle, dialog and Save on this page silently does nothing. There is no server-side error; only the browser console says so.
| Option | Description |
|---|---|
:config (required) |
The module that use PhoenixFlags |
:on_mount |
List of on_mount hooks for the live session (e.g. authentication) |
:app_js |
Path to the app's JS bundle. Defaults to "/assets/js/app.js" |
:app_js_type |
The script tag's type. Pass "module" for an ESM bundle |
:favicon |
The page's icon. Defaults to "data:,", so the browser asks for nothing |
:csp_nonce_assign_key |
The conn assign holding your CSP nonce; the script tag carries it |
:live_socket_path is deprecated: it never had an effect, since the socket path
is whatever your app.js connects to.
The stylesheet is served from the page's own route, and the variant bars are
SVG, so the page needs no 'unsafe-inline' for styles.
How It Works
- Self-contained — ships its own CSS and HTML layout, served via an inline asset route with a content-hashed, immutable URL
- Isolated
live_session— won't conflict with your app's sessions - Session-based config — the router macro passes your config module through the LiveView session
- One dialog at a time — the editor is rendered once for whichever flag is being edited, derived from the current entries rather than held in its own assign, so a cluster update can't leave it showing a stale value
- No client-side trust — event payloads are re-validated server-side. A
:variantsave is rebuilt from the declared variants in declaration order, so a forged field cannot introduce a variant name or reorder the buckets
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
actor_fnis called with the LiveView socket during mount. The returned string is stored as the actor in the audit log.- Every successful
update_entryinserts a row intosystem_flags_auditwith the key, old value, new value, actor, and timestamp. - When
audit: false(the default), zero additional code runs — no overhead. - Audit insert failures are logged but never block the update.
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
MyApp.SystemConfig.audit_log(limit: 50) # the newest 50
MyApp.SystemConfig.audit_log("enable_benefits", limit: 10)
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 env | FunWithFlags | PhoenixFlags | |
|---|---|---|---|
| Runtime changes | No (deploy required) | Yes | Yes |
| Typed values | No | Boolean only | 8 types + validation |
| A/B testing | No | No | Weighted variants, consistent hash |
| Per-user targeting | No | Actor gates (code) | Runtime rules on any attribute |
| Caching | N/A (in-memory) | ETS | :persistent_term (zero-copy) |
| Cluster sync | No | Redis/Ecto polling | Direct node messaging |
| Admin UI | N/A | No | Built-in dashboard, one router line |
| External deps | None | Redis (optional) | None |
| Compile-time checks | No | No | Yes (flag/2 macro) |
| Auto-seeding | No | No | Yes (on startup) |
API Reference
Module API (generated by use PhoenixFlags)
| Function | Description |
|---|---|
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, opts \\ []) |
Add a targeting rule (:conditions, :value, optional :position); actor: for the audit log |
delete_target(id, opts \\ []) |
Delete a targeting rule; actor: for the audit log |
audit_log(key_or_opts \\ []) |
Audit entries, newest first: every key's, or one key's; limit: caps them (requires audit: true) |
prune_undeclared() |
Delete stored flags no longer declared, with their rules |
Test API (generated in :test env as MyModule.Test)
| Function | Description |
|---|---|
stub(key, value) |
Process-scoped override, no DB |
insert_entry(key, value, opts) |
DB insert/upsert for cross-process tests |
Router API
| Macro | Description |
|---|---|
flags_dashboard(path, opts) |
Mount the embedded dashboard LiveView |
Migration API
| Function | Description |
|---|---|
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
| Function | Description |
|---|---|
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.exspoints at its ownphoenix_flags_benchdatabase, created and migrated on first use. Scripts that require it therefore cannot disturbphoenix_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.