Limited time 50% off everything

From the blog

Plan-Based Feature Gating and Entitlements in Phoenix

By Liam Killingback ·

Plan-Based Feature Gating and Entitlements in Phoenix

Every SaaS eventually grows a line of code like this:

if user.plan == "pro" do
  render_custom_domains(assigns)
end

One of them is fine. Forty of them, scattered across contexts, controllers, LiveViews and templates, is a slow-moving disaster. When you add a plan, rename a tier, run a promotion, or grandfather three enterprise accounts onto old pricing, you have to find every one of those checks and get it right. Miss one in a LiveView and a free account sees an upgrade button. Miss one in a context function and a free account actually gets the feature.

Phoenix feature gating is much easier when you stop asking “what plan is this account on?” and start asking “what is this account entitled to?” That single change turns plan logic into data, gives you one place to answer the question, and makes the whole thing testable.

This post builds a small entitlements layer for a Phoenix app: a plan catalog, a resolver, gating at each layer of the stack, caching, per-account overrides, and quotas that reset. It is the entitlements half of the metering story. For the counting half, see Phoenix usage metering in real time with ETS.

Feature flags and entitlements are not the same thing

These two get conflated constantly, and the confusion produces bad architecture, so it is worth being precise.

A feature flag is an engineering control. It answers “is this code path on?” It is usually temporary, it is owned by the team shipping the feature, and it is meant to be flipped and then deleted. Rollout percentages, kill switches, and internal-only betas are feature flags.

An entitlement is a commercial control. It answers “has this customer paid for this?” It is permanent, it is owned by whoever sets pricing, and it maps directly to your pricing page. Seat limits, project limits, API quotas, and “SSO is on the Enterprise plan” are entitlements.

They look identical at the call site (both are a boolean check) which is why people build one system for both. Do not. Flags churn weekly and can be wrong without consequence. Entitlements are load bearing: getting one wrong either gives away revenue or blocks a paying customer. They deserve different storage, different caching, different auditing, and different tests.

If you already run a flag library, keep it. Add entitlements alongside it.

Step 1: model the plan catalog as data

Start with the thing your pricing page already describes. Put it in code, not in the database. A plan catalog is small, changes rarely, needs to be identical across every node, and should be reviewable in a pull request.

# lib/my_app/plans.ex
defmodule MyApp.Plans do
  @moduledoc """
  The commercial plan catalog. This is the source of truth for what each
  tier includes. It mirrors the pricing page, so changes go through review.
  """

  @plans %{
    "free" => %{
      id: "free",
      name: "Free",
      features: [:api_access],
      limits: %{projects: 1, seats: 1, api_calls: 1_000}
    },
    "pro" => %{
      id: "pro",
      name: "Pro",
      features: [:api_access, :custom_domains, :webhooks],
      limits: %{projects: 25, seats: 10, api_calls: 250_000}
    },
    "scale" => %{
      id: "scale",
      name: "Scale",
      features: [:api_access, :custom_domains, :webhooks, :sso, :audit_log],
      limits: %{projects: :unlimited, seats: 100, api_calls: 5_000_000}
    }
  }

  @default "free"

  def get(id) when is_binary(id), do: Map.get(@plans, id, @plans[@default])
  def get(_), do: @plans[@default]

  def all, do: Map.values(@plans)

  @doc "The cheapest plan that includes a feature, for upgrade prompts."
  def cheapest_with(feature) do
    Enum.find(all(), fn plan -> feature in plan.features end)
  end
end

Two details matter here.

:unlimited is a real value, not a very large number. Sentinel integers like 999_999 leak into invoices and dashboards and eventually someone hits one. Make the unlimited case explicit so the comparison code has to handle it.

cheapest_with/1 exists because a denial is a sales moment. When you block something, you want to say “custom domains are on Pro” rather than “forbidden”. That requires knowing which plan would unlock it. (all/0 returns map values, so if you care about the order, sort by price explicitly rather than relying on map ordering.)

Step 2: one resolver, one answer

Everything in the app asks the same module. No other code reads account.plan_id.

# lib/my_app/entitlements.ex
defmodule MyApp.Entitlements do
  alias MyApp.Accounts.Account
  alias MyApp.Plans

  defmodule Denied do
    defstruct [:feature, :reason, :current_plan, :required_plan, :limit, :used]
  end

  @doc "Does this account have the feature at all?"
  def feature?(%Account{} = account, feature) do
    feature in features(account)
  end

  @doc "The numeric ceiling for a limit key. Returns an integer or :unlimited."
  def limit(%Account{} = account, key) do
    account |> limits() |> Map.get(key, 0)
  end

  @doc "Gate a boolean feature. Returns :ok or {:error, %Denied{}}."
  def check(%Account{} = account, feature) do
    if feature?(account, feature) do
      :ok
    else
      {:error,
       %Denied{
         feature: feature,
         reason: :not_in_plan,
         current_plan: account.plan_id,
         required_plan: Plans.cheapest_with(feature)
       }}
    end
  end

  @doc "Gate a countable resource. `used` is the current count."
  def check_quota(%Account{} = account, key, used) do
    case limit(account, key) do
      :unlimited ->
        :ok

      ceiling when used < ceiling ->
        :ok

      ceiling ->
        {:error,
         %Denied{
           feature: key,
           reason: :quota_exceeded,
           current_plan: account.plan_id,
           required_plan: next_plan_above(key, ceiling),
           limit: ceiling,
           used: used
         }}
    end
  end

  defp features(%Account{} = account) do
    plan = Plans.get(account.plan_id)
    Enum.uniq(plan.features ++ (account.feature_overrides || []))
  end

  defp limits(%Account{} = account) do
    plan = Plans.get(account.plan_id)
    Map.merge(plan.limits, account.limit_overrides || %{})
  end

  defp next_plan_above(key, ceiling) do
    Enum.find(Plans.all(), fn plan ->
      case Map.get(plan.limits, key) do
        :unlimited -> true
        value when is_integer(value) -> value > ceiling
        _ -> false
      end
    end)
  end
end

The API surface is deliberately small: feature?/2 for rendering decisions, check/2 and check_quota/3 for anything that enforces. The check functions return a %Denied{} struct rather than false, because the caller almost always needs to explain the denial to a human.

feature_overrides and limit_overrides are columns on the account. That is what makes the sales team’s life bearable, and it is the subject of a later section.

Step 3: gate at the right layers

There are four places you can gate, and they are not interchangeable. Doing all four is not redundancy, it is defence in depth plus decent UX.

The context is the only authoritative gate

If the check is not in the context function that performs the action, the feature is not gated. Controllers and LiveViews can be bypassed. Contexts cannot, because that is where the write happens.

# lib/my_app/projects.ex
defmodule MyApp.Projects do
  import Ecto.Query
  alias MyApp.{Entitlements, Repo}
  alias MyApp.Accounts.Account
  alias MyApp.Projects.Project

  def create_project(%Account{} = account, attrs) do
    used = count_for(account)

    with :ok <- Entitlements.check_quota(account, :projects, used) do
      %Project{account_id: account.id}
      |> Project.changeset(attrs)
      |> Repo.insert()
    end
  end

  def count_for(%Account{} = account) do
    Repo.aggregate(from(p in Project, where: p.account_id == ^account.id), :count)
  end
end

with gives you the shape you want for free. On success you get {:ok, project}. On denial you get {:error, %Denied{}}, which is distinguishable from {:error, %Ecto.Changeset{}} by pattern match, so the caller can render an upgrade prompt instead of a form error.

One honest caveat: a count plus an insert is not atomic. Two concurrent requests can both read used = 0 and both insert. For most limits nobody cares, and if they do, the fix is a partial unique index or a Repo.transaction with a row lock on the account. Decide per limit rather than pretending the race does not exist.

The router plug catches whole sections

For features that own a route namespace (audit log, SSO settings, an admin area), a plug is cleaner than a check in every action.

# lib/my_app_web/plugs/require_feature.ex
defmodule MyAppWeb.Plugs.RequireFeature do
  import Plug.Conn
  import Phoenix.Controller

  def init(feature), do: feature

  def call(conn, feature) do
    case MyApp.Entitlements.check(conn.assigns.current_account, feature) do
      :ok ->
        conn

      {:error, denied} ->
        conn
        |> put_flash(:error, upgrade_message(denied))
        |> redirect(to: "/settings/billing?feature=#{denied.feature}")
        |> halt()
    end
  end

  defp upgrade_message(%{required_plan: nil, feature: feature}),
    do: "#{humanize(feature)} is not available on your plan."

  defp upgrade_message(%{required_plan: plan, feature: feature}),
    do: "#{humanize(feature)} is available on the #{plan.name} plan."

  defp humanize(feature), do: feature |> to_string() |> String.replace("_", " ")
end
# lib/my_app_web/router.ex
scope "/settings", MyAppWeb do
  pipe_through [:browser, :require_authenticated_user]

  # Controller routes: the plug guards the whole scope.
  scope "/audit" do
    pipe_through [{MyAppWeb.Plugs.RequireFeature, :audit_log}]
    get "/", AuditLogController, :index
  end

  # LiveView routes: on_mount guards the socket as well as the first request.
  live_session :sso, on_mount: [{MyAppWeb.Entitle, {:require, :sso}}] do
    live "/sso", SsoLive.Index
  end
end

LiveView gets an on_mount hook

Plugs do not run on the websocket reconnect, so a LiveView route needs its own hook. This is the one people forget, and it is exactly the gap an attacker walks through.

# lib/my_app_web/live/entitle.ex
defmodule MyAppWeb.Entitle do
  import Phoenix.LiveView
  import Phoenix.Component

  alias MyApp.Entitlements

  def on_mount({:require, feature}, _params, _session, socket) do
    account = socket.assigns.current_account

    case Entitlements.check(account, feature) do
      :ok -> {:cont, assign_entitlements(socket, account)}
      {:error, _denied} -> {:halt, redirect(socket, to: "/settings/billing")}
    end
  end

  def on_mount(:assign, _params, _session, socket) do
    {:cont, assign_entitlements(socket, socket.assigns.current_account)}
  end

  defp assign_entitlements(socket, account) do
    assign(socket, :entitlements, Entitlements.summary(account))
  end
end

summary/1 is a small addition to the resolver that returns a plain map, so templates never call functions with side effects:

def summary(%Account{} = account) do
  %{
    plan: Plans.get(account.plan_id),
    features: MapSet.new(features(account)),
    limits: limits(account)
  }
end

Templates decide what to show, not what is allowed

With @entitlements assigned, the markup stays readable, and the important part is that a hidden button is a UX decision and never a security boundary.

<.link :if={:custom_domains in @entitlements.features} navigate={~p"/settings/domains"}>
  Custom domains
</.link>

<.upgrade_card
  :if={:custom_domains not in @entitlements.features}
  feature="Custom domains"
  plan="Pro"
/>

Showing the locked feature with an upgrade card generally converts better than hiding it, because a customer cannot want something they never knew existed. That is a pricing decision rather than a technical one, but the entitlements layer is what makes it a one-line change.

Step 4: cache the answer, invalidate on change

Plans.get/1 is a compile-time map lookup and costs nothing. The account is usually already loaded. So for most apps, no cache is needed, and adding one first is premature.

You need a cache when entitlements are checked on a hot path, most obviously an API pipeline that gates every request. Then the account load is the cost, not the resolution. ETS handles it well:

# lib/my_app/entitlements/cache.ex
defmodule MyApp.Entitlements.Cache do
  use GenServer

  @table :entitlements_cache

  def start_link(_), do: GenServer.start_link(__MODULE__, :ok, name: __MODULE__)

  def summary(account_id) do
    case :ets.lookup(@table, account_id) do
      [{^account_id, summary}] ->
        summary

      [] ->
        summary = account_id |> MyApp.Accounts.get_account!() |> MyApp.Entitlements.summary()
        :ets.insert(@table, {account_id, summary})
        summary
    end
  end

  def invalidate(account_id) do
    :ets.delete(@table, account_id)
    Phoenix.PubSub.broadcast(MyApp.PubSub, "entitlements", {:invalidate, account_id})
  end

  @impl true
  def init(:ok) do
    :ets.new(@table, [:named_table, :public, :set, read_concurrency: true])
    Phoenix.PubSub.subscribe(MyApp.PubSub, "entitlements")
    {:ok, %{}}
  end

  @impl true
  def handle_info({:invalidate, account_id}, state) do
    :ets.delete(@table, account_id)
    {:noreply, state}
  end
end

The PubSub broadcast is what makes this correct on more than one node. Without it, a customer upgrades, node A clears its cache, and node B keeps refusing the feature until it restarts. That bug is invisible in development and infuriating in production.

Call invalidate/1 from exactly three places: your Stripe webhook handler, the admin override form, and anywhere a plan change is written. If you find yourself calling it from a fourth place, something else is writing plan state and should not be.

Step 5: overrides and grandfathering

The plan catalog covers the pricing page. Reality covers everything else: the design partner who gets SSO free for a year, the customer who negotiated 50 seats instead of 10, the early adopters on a tier you retired in 2024.

Two workable approaches, with a real tradeoff.

Per-account overrides. Two JSONB columns on the account, merged over the plan, which is what the resolver above already does.

# priv/repo/migrations/..._add_entitlement_overrides_to_accounts.exs
def change do
  alter table(:accounts) do
    add :feature_overrides, {:array, :string}, default: []
    add :limit_overrides, :map, default: %{}
  end
end

Simple, visible in the admin UI, and it makes exceptions obvious. The downside is drift: after two years you have 300 accounts with bespoke overrides and no idea which are still intentional. Add a notes and an expires_at field from day one if you go this way.

Versioned plans. Keep retired plans in the catalog ("pro_2024", "pro_2026") and never mutate a plan in place. Grandfathering becomes “the account still points at the old plan id”, which is honest and self-documenting. The downside is catalog sprawl and the discipline required to never edit an existing entry.

In practice most teams want both: versioned plans for pricing changes that affect a cohort, per-account overrides for one-off deals. What you should avoid is a third mechanism, like a separate account_features join table that duplicates what overrides already do.

Note that limit_overrides comes back from JSONB with string keys. Either cast it on load or key your limits with strings throughout. Mixing %{projects: 25} and %{"projects" => 25} produces a limit that silently reads as zero, which is the most annoying possible failure mode.

Step 6: quotas that reset are a different animal

Boolean features and seat counts are static. “250,000 API calls per month” is not: it needs a counter, a reset boundary, and a decision about what happens when the customer blows through it.

That counter should not live in Postgres on the request path. Counting in ETS and flushing periodically is the pattern covered in detail in the ETS metering post; the entitlements layer just supplies the ceiling.

defmodule MyAppWeb.Plugs.MeterApiCall do
  import Plug.Conn

  def init(opts), do: opts

  def call(conn, _opts) do
    account = conn.assigns.current_account
    used = MyApp.Metering.current_period(account.id, :api_calls)

    case MyApp.Entitlements.check_quota(account, :api_calls, used) do
      :ok ->
        MyApp.Metering.increment(account.id, :api_calls)
        conn

      {:error, denied} ->
        conn
        |> put_status(429)
        |> Phoenix.Controller.json(%{
          error: "quota_exceeded",
          limit: denied.limit,
          used: denied.used,
          upgrade_url: "https://app.example.com/settings/billing"
        })
        |> halt()
    end
  end
end

Two product decisions hide in that code block. First, hard cap or overage? A hard cap protects the customer from a surprise bill and protects you from abuse. Overage bills them for what they used and avoids breaking their integration at 3am. Overage generally makes more money and generates fewer support tickets, but only if you meter accurately and warn at 80 percent. Second, when does the period reset? Aligning it to the Stripe billing period rather than the calendar month avoids a whole category of “my invoice does not match my dashboard” tickets. The full picture is in the usage-based billing guide.

Testing entitlements

Entitlements are exactly the kind of logic that deserves boring, exhaustive tests, because the failure mode is revenue.

defmodule MyApp.EntitlementsTest do
  use MyApp.DataCase, async: true

  alias MyApp.Entitlements
  alias MyApp.Entitlements.Denied

  test "free accounts cannot create a second project" do
    account = account_fixture(plan_id: "free")
    {:ok, _} = MyApp.Projects.create_project(account, %{name: "First"})

    assert {:error, %Denied{reason: :quota_exceeded, limit: 1, used: 1}} =
             MyApp.Projects.create_project(account, %{name: "Second"})
  end

  test "an override beats the plan" do
    account = account_fixture(plan_id: "free", feature_overrides: ["custom_domains"])
    assert Entitlements.feature?(account, :custom_domains)
  end

  test "unlimited limits never deny" do
    account = account_fixture(plan_id: "scale")
    assert :ok = Entitlements.check_quota(account, :projects, 10_000)
  end

  test "denials name the plan that unlocks the feature" do
    account = account_fixture(plan_id: "free")
    assert {:error, %Denied{required_plan: %{id: "pro"}}} = Entitlements.check(account, :webhooks)
  end
end

Add one property that is easy to overlook: every feature named anywhere in your templates should exist in at least one plan. A typo like :custom_domain instead of :custom_domains fails closed and silently hides a feature from paying customers. A test that walks the catalog and compares it against a canonical list of features catches that in CI.

Note the feature_overrides: ["custom_domains"] string in the fixture versus the :custom_domains atom in the check. That mismatch is the bug the resolver has to handle, so decide early whether overrides are stored as strings and cast on read, and then test it.

Common mistakes

Gating on plan name instead of capability. if account.plan_id == "pro" breaks the moment you add a plan above Pro. Ask for the capability, always.

Checking only in the UI. Hiding a button is not authorization. If the context function does not check, the feature is open.

Forgetting the LiveView reconnect path. A plug guards the initial HTTP request. The socket mount is a separate entry point and needs on_mount.

Caching without invalidation. A customer who pays and does not immediately get the feature will ask for a refund.

Encoding pricing in migrations. Plan definitions in database rows mean pricing changes need a deploy plus a data migration plus a rollback plan. Keep the catalog in code.

Using entitlements as feature flags. Rollout percentages and kill switches belong somewhere else, on a system you are happy to be wrong about.

Skip the plumbing

Everything above is maybe 300 lines, and it is the same 300 lines in every SaaS. Aurora Meter ships it as a library: a plan catalog, entitled?/2 and gate/3, per-account overrides, ETS-backed counters on the hot path, and quota checks that share a single atomic step with the metering write. The core is free and MIT licensed at github.com/liamkillingback/aurora-meter; Aurora Meter Pro adds Stripe reporting, reconciliation, and a usage dashboard.

If you also need the accounts, auth and billing tables this sits on top of, phx_saas ships those already wired, so you can start at the entitlements layer instead of building up to it. A Builder Pass gets you that plus every other template for a single lifetime price.

Summary

Phoenix feature gating stops being a maintenance problem the moment you treat entitlements as a first-class concept rather than a scattering of plan comparisons:

  • Put the plan catalog in code, as data, reviewable in a pull request.
  • Resolve through one module. Nothing else reads plan_id.
  • Return a rich denial, not false, so you can prompt an upgrade instead of showing a wall.
  • Enforce in contexts, guard routes with a plug, guard LiveViews with on_mount, and let templates decide only what to show.
  • Cache only when the hot path demands it, and invalidate across nodes with PubSub.
  • Give yourself per-account overrides before sales asks for them.
  • Test the denials as carefully as you test the happy path.

Do that, and adding a plan becomes a pull request against one map instead of an archaeology project.