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

  • Zero-cost reads — :persistent_term cache, 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/2 macro 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:

  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:

  • 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 :string to :integer; "true" would be invalid as an integer, so it is reset to the declared default. A :secret on 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

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

  • 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_value and new_value are 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/2 returns the decrypted plaintext from the :persistent_term cache; treat it with the same care as an env var.
  • all_grouped/0 returns entries with the stored ciphertext in entry.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.

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

  • 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_id and "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 :boolean rule must be "true"/"false" and a :variant rule must name a declared variant.
  • :secret flags 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/1 starts with an empty one; pass context: PhoenixFlags.context() into the task, or set it again inside.
  • Cost when unused is a single :persistent_term read. 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_term values 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_term values. 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?

HelperMechanismVisible toUse when
stub/2Process dictionaryThe test, and Tasks it startsUnit 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. 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 :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:

  • Toggle switches for booleans, which save on click
  • An Edit dialog for every other type, opened from the row
  • Percentage bars for :variant flags, with a weights editor and a live total
  • Set / Not set for :secret flags, 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 using import() for code splitting, produce one. Loaded as a classic script it throws import declarations may only appear at top level of a module before 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.

OptionDescription
:config (required)The module that use PhoenixFlags
:on_mountList of on_mount hooks for the live session (e.g. authentication)
:app_jsPath to the app's JS bundle. Defaults to "/assets/js/app.js"
:app_js_typeThe script tag's type. Pass "module" for an ESM bundle
:faviconThe page's icon. Defaults to "data:,", so the browser asks for nothing
:csp_nonce_assign_keyThe 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 :variant save 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_fn is called with the LiveView socket during mount. The returned string is stored as the actor in the audit log.
  • Every successful update_entry inserts a row into system_flags_audit with 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 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, 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)

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.