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

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:

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

Caveats

A/B Testing

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

defmodule MyApp.SystemConfig do
  use PhoenixFlags, otp_app: :my_app, repo: MyApp.Repo

  flag "checkout_flow",
    type: :variant,
    category: "experiments",
    label: "Checkout flow experiment",
    variants: [
      {"Control",  "control",  90},
      {"New flow", "new_flow", 10}
    ]
end
MyApp.SystemConfig.variant("checkout_flow", user.id)
#=> "control"

MyApp.SystemConfig.variant("checkout_flow", user.id)
#=> "control"   # always, for this user

MyApp.SystemConfig.get("checkout_flow")
#=> ** (PhoenixFlags.Error) "checkout_flow" is a :variant flag and has no single
#     value. Read it with variant("checkout_flow", identity) instead of get/2.

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

Gradual rollouts are sticky

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

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

Assignment lifetime (:ttl)

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

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

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

Independence and seeds

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

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

Tracking exposures

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

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

MyApp.SystemConfig.variant("checkout_flow", user.id, telemetry: true)

Testing

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

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

Targeting

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

1. Provide a context

Set it once where you already have the current user:

# lib/my_app_web/plugs/flag_context.ex
defmodule MyAppWeb.Plugs.FlagContext do
  def init(opts), do: opts

  def call(conn, _opts) do
    case conn.assigns[:current_user] do
      nil -> conn
      user ->
        PhoenixFlags.put_context(user_id: user.id, company_id: user.company_id)
        conn
    end
  end
end

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

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

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

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

2. Add a rule

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

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

MyApp.SystemConfig.targets("enable_benefits")
MyApp.SystemConfig.delete_target(target_id)

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

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:

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

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

Things worth knowing

Architecture

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

Why :persistent_term?

Cluster Replication

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

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

One Config Module per Repo

All flags live in a single system_flags table. 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 :secret values, which are write-only in the UI but whose changes are still actor-attributed in the audit log. Protect it yourself, at both layers: a router pipeline for the initial HTTP request (pipe_through) and an :on_mount hook for the LiveView connection. A pipeline alone does not guard the WebSocket mount.

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

The dialog closes on Save, Cancel, the ×, Escape, or a click on the backdrop. Keyboard focus moves into the dialog when it opens, 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.

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

Custom UI

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

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

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

Audit Log

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

Setup

Enable audit logging in your config module:

defmodule MyApp.SystemConfig do
  use PhoenixFlags,
    otp_app: :my_app,
    repo: MyApp.Repo,
    audit: true,
    actor_fn: &MyApp.SystemConfig.current_user/1

  # Extract the actor from the LiveView socket or Plug conn
  def current_user(%Phoenix.LiveView.Socket{} = socket) do
    socket.assigns.current_admin.email
  end

  def current_user(_), do: "system"

  # ... flag declarations
end

Then generate a migration to add the audit table:

defmodule MyApp.Repo.Migrations.UpgradeSystemFlagsV2 do
  use Ecto.Migration

  def up, do: PhoenixFlags.Migration.up(version: 2)
  def down, do: PhoenixFlags.Migration.down(version: 2)
end

How It Works

Querying the Audit Log

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

MyApp.SystemConfig.audit_log("enable_benefits")
#=> [%PhoenixFlags.AuditLog{...}, ...]  # filtered by key

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.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.