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, stale flags auto-removed
- 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.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:
- 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 deleted from the database
- 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 reset the value — if you change a flag from
:booleanto:integer, the old value"true"would be invalid, so it's reset to the declared default
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 the new value, submit empty to clear 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, 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?
| Helper | Mechanism | Visible to | Use when |
|---|---|---|---|
stub/2 | Process dictionary | Same process only | 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.
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
: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- 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.
Dashboard Options
flags_dashboard "/flags",
config: MyApp.SystemConfig, # required
on_mount: [{MyAppWeb.AdminAuth, :ensure_authenticated}] # auth hooks
| Option | Description |
|---|---|
:config (required) | The module that use PhoenixFlags |
:on_mount | List of on_mount hooks for the live session (e.g. authentication) |
:live_socket_path | Defaults to "/live" |
:app_js | Path to the app's JS bundle. Defaults to "/assets/js/app.js" |
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
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) | 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)
| 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.