FullstackPhoenix

Release Features Safely With Feature Flags in Phoenix

phoenixfeature-flagsfun-with-flagsteamsliveview

When I want to ship a risky change to a subset of accounts first, I need a flag I can flip at runtime instead of a deploy I cannot undo. A boolean in the database is not enough either, because I want to target a specific user or a whole team without writing custom queries every time.

In this tutorial I install the fun_with_flags feature with Live SaaS Kit, enable a flag for one user and then for an entire team, and gate a LiveView behind it. The starting point is a project with authentication and teams already installed, because those features provide the actors the flag library reads.

Step 1 - Install the fun_with_flags feature

First step is to run the installer.

mix saaskit.feature.install fun_with_flags
mix deps.get

Two packages land in mix.exs.

# mix.exs
{:fun_with_flags, "~> 1.13.0"},
{:fun_with_flags_ui, "~> 1.1.0"},

The installer also creates the migration for the flag persistence table.

# priv/repo/migrations/TIMESTAMP_create_feature_flags_table.exs
create table(:fun_with_flags_toggles, primary_key: false) do
add :id, :bigserial, primary_key: true
add :flag_name, :string, null: false
add :gate_type, :string, null: false
add :target, :string, null: false
add :enabled, :boolean, null: false
end
create index(
:fun_with_flags_toggles,
[:flag_name, :gate_type, :target],
unique: true,
name: "fwf_flag_name_gate_target_idx"
)

The installer runs mix ecto.migrate for me, so there is no extra step here.

Step 2 - Look at the config

The feature injects a persistence block and a cache-bust block into config/config.exs. The cache-bust part uses the application's Phoenix PubSub so flag changes propagate across nodes in a cluster.

# config/config.exs
config :fun_with_flags, :persistence,
adapter: FunWithFlags.Store.Persistent.Ecto,
repo: MyApp.Repo,
ecto_primary_key_type: :id
config :fun_with_flags, :cache_bust_notifications,
enabled: true,
adapter: FunWithFlags.Notifications.PhoenixPubSub,
client: MyApp.PubSub

Note that the FunWithFlags supervisor starts itself when the application loads. I do not need to add a child to application.ex.

Step 3 - Confirm the actors

The feature generates two FunWithFlags.Actor implementations because authentication and teams are installed.

# lib/my_app/users/user_flag_actor.ex
defimpl FunWithFlags.Actor, for: MyApp.Users.User do
def id(%{id: id}), do: "user:#{id}"
end
# lib/my_app/teams/team_flag_actor.ex
defimpl FunWithFlags.Actor, for: MyApp.Teams.Team do
def id(%{id: id}), do: "team:#{id}"
end

The reason for two implementations is that the same flag can be enabled for one specific user, for one whole team, or for both. So I can roll a change out to my own account first, then to an entire pilot team, before flipping it on globally.

Step 4 - Enable a flag for a user, then for a team

There is a MyApp.Feature wrapper that knows how to read a flag with a scope-shaped context.

# lib/my_app/feature.ex
def enabled?(flag, %{team: team, user: user})
when not is_nil(team) and not is_nil(user) do
FunWithFlags.enabled?(flag, for: team) || FunWithFlags.enabled?(flag, for: user)
end

To enable a flag I call FunWithFlags.enable/2 directly from iex, with either a user or a team as the actor.

# iex -S mix
user = MyApp.Users.get_user_by_email("me@example.com")
team = MyApp.Teams.get_team!(user.current_team_id)
FunWithFlags.enable(:new_dashboard, for_actor: user)
FunWithFlags.enable(:new_dashboard, for_actor: team)

The first call targets one specific user. The second targets every member of that team because of the team actor implementation from step 3.

Step 5 - Gate a LiveView behind the flag

With MyApp.Feature.enabled?/2 in place, I gate a LiveView render based on the current scope.

# lib/my_app_web/live/dashboard_live.ex
def render(%{current_scope: scope} = assigns) do
if MyApp.Feature.enabled?(:new_dashboard, scope) do
~H"""
<.new_dashboard scope={@current_scope} />
"""
else
~H"""
<.classic_dashboard scope={@current_scope} />
"""
end
end

scope already carries the :user and :team keys the wrapper expects, so the function returns true when either the user or the team has the flag on, and false otherwise.

Step 6 - Open the flag dashboard

The feature also mounts the FunWithFlags UI through fun_with_flags_ui. With the admin feature installed, the router exposes it under the admin pipes.

# lib/my_app_web/router.ex
scope path: "/admin/feature-flags" do
pipe_through [:browser, :require_authenticated_user, :require_admin_user]
forward "/", FunWithFlags.UI.Router, namespace: "admin/feature-flags"
end

So as a superuser, I open /admin/feature-flags and see the :new_dashboard flag with the actors I enabled in step 4. From that page I can flip flags on and off without touching iex. However, this dashboard does not include any test-mode helpers — flags set during a test run persist between tests unless I clear them explicitly in setup.

Final Result

I can now flip :new_dashboard on for a single user, a whole team, or globally, and the LiveView reacts on the next render. A natural next step is to install payments and stripe_subscription, then use a flag like :billing_v2 to roll the new checkout flow out gradually.