Using Bandera with Phoenix LiveView

Copy Markdown View Source

This is a short, practical guide to gating UI and behavior in a Phoenix LiveView app with Bandera. It assumes Bandera is already installed and configured; see the README for that.

LiveViews are just processes, so calling Bandera from one is nothing special: Bandera.enabled?/2 works in mount/3, handle_event/3, handle_info/2, and inside ~H templates. What's worth spelling out is wiring the current user in as the actor, and testing. Both are covered below.

1. Identify the current user

Most flags are evaluated "for" a user. Implement the Bandera.Actor protocol so Bandera can derive a stable id for your user struct, and Bandera.Group if you use group gates (e.g. roles):

defimpl Bandera.Actor, for: MyApp.Accounts.User do
  def id(user), do: "user:#{user.id}"
end

defimpl Bandera.Group, for: MyApp.Accounts.User do
  def in?(user, group_name), do: group_name in user.roles
end

The simplest way to have current_user available in every LiveView is the on_mount hook generated by mix phx.gen.auth:

# lib/my_app_web/router.ex
live_session :default, on_mount: [{MyAppWeb.UserAuth, :mount_current_user}] do
  live "/dashboard", DashboardLive
end

That puts socket.assigns.current_user in place before your mount/3 runs.

2. Check a flag in a LiveView

Read the flag in mount/3, assign the result, and branch in the template with :if:

defmodule MyAppWeb.DashboardLive do
  use MyAppWeb, :live_view

  @impl true
  def mount(_params, _session, socket) do
    user = socket.assigns.current_user

    {:ok, assign(socket, :new_dashboard?, Bandera.enabled?(:new_dashboard, for: user))}
  end

  @impl true
  def render(assigns) do
    ~H"""
    <.new_dashboard :if={@new_dashboard?} />
    <.classic_dashboard :if={!@new_dashboard?} />
    """
  end
end

Assigning once in mount/3 (rather than calling Bandera.enabled?/2 directly in the template) keeps the check out of the render path and makes the value easy to override in tests.

A reusable helper keeps controllers and LiveViews consistent:

# lib/my_app_web/feature_flags.ex
defmodule MyAppWeb.FeatureFlags do
  def assign_flag(socket_or_conn, key, flag, actor) do
    Phoenix.Component.assign(socket_or_conn, key, Bandera.enabled?(flag, for: actor))
  end
end

3. Gate events and actions

Don't rely on the UI alone. Re-check the flag where the action actually happens, so a hidden control can't be triggered by a crafted event:

@impl true
def handle_event("publish", _params, socket) do
  if Bandera.enabled?(:publishing, for: socket.assigns.current_user) do
    {:noreply, do_publish(socket)}
  else
    {:noreply, put_flash(socket, :error, "That feature isn't available yet.")}
  end
end

4. (Optional) React to flag changes live

mount/3 reads the flag once. If you want a running LiveView to pick up a flag change without a reload, re-read the flag on some trigger, for example a periodic tick, or a Phoenix.PubSub broadcast you send when toggling flags:

def mount(_params, _session, socket) do
  if connected?(socket), do: Phoenix.PubSub.subscribe(MyApp.PubSub, "feature_flags")
  {:ok, assign_new_dashboard(socket)}
end

def handle_info(:flags_changed, socket), do: {:noreply, assign_new_dashboard(socket)}

defp assign_new_dashboard(socket) do
  assign(socket, :new_dashboard?, Bandera.enabled?(:new_dashboard, for: socket.assigns.current_user))
end

For most apps the mount-time read is enough; reach for this only when live toggling matters.

5. Outside LiveView

The same calls work everywhere: controllers, function components, and plugs:

# controller
if Bandera.enabled?(:beta_export, for: conn.assigns.current_user) do
  # ...
end
<%!-- a function component receiving the flag as an assign --%>
<.banner :if={@promo_enabled?} />

6. Testing LiveViews

The test layer scopes overrides to the test process and its descendants. A LiveView started by Phoenix.LiveViewTest is a descendant (Phoenix propagates $callers), so flags you set in the test are visible inside the LiveView. Tests stay async: true, never touch the database, and clean up automatically.

One-time setup (see the README for details):

# config/test.exs
config :bandera, store: Bandera.Store.ProcessScoped

# test/test_helper.exs
Bandera.Test.start()

Then in a LiveView test, declare flags with the @tag feature_flags: tag or set them imperatively:

defmodule MyAppWeb.DashboardLiveTest do
  use MyAppWeb.ConnCase, async: true
  use Bandera.Test
  import Phoenix.LiveViewTest

  setup :register_and_log_in_user

  @tag feature_flags: [new_dashboard: true]
  test "renders the new dashboard when the flag is on", %{conn: conn} do
    {:ok, _live, html} = live(conn, ~p"/dashboard")
    assert html =~ "New dashboard"
  end

  test "falls back to the classic dashboard when off", %{conn: conn} do
    {:ok, _live, html} = live(conn, ~p"/dashboard")
    assert html =~ "Classic dashboard"
  end

  test "enables a flag for one actor only", %{conn: conn, user: user} do
    enable_flag(:new_dashboard, user)
    {:ok, _live, html} = live(conn, ~p"/dashboard")
    assert html =~ "New dashboard"
  end
end

Notes:

  • @tag feature_flags: [new_dashboard: true] sets a global boolean override, so it's honored even when the LiveView checks enabled?(:new_dashboard, for: user).
  • Use enable_flag(flag, actor) / disable_flag(flag, actor) to override for a specific user, useful for testing per-actor or per-group gates.
  • register_and_log_in_user is the mix phx.gen.auth helper; it puts the user in the session so current_user is assigned.