Phoenix Scopes and Generators
Verified against Elixir 1.20, Phoenix 1.8, LiveView 1.2, Ecto 3.14 — October 2026
RULES — Follow these with no exceptions
- Check
config :my_app, :scopesbefore generating. Generators read it at mix-task time; with no default entry (nophx.gen.authyet) they silently emit unscoped contexts with no PubSub topic and no owner column. Runphx.gen.authfirst, or add the entry. - Take the default scope unless the data is truly global.
--no-scope(lookup tables, public content) drops the scope argument, the owner column, thesubscribe_*/broadcast_*functions and the cross-scope tests.--scope nameonly accepts a configured entry, and combining the two flags raises. - Don't pass the owner as a field. With the default scope,
user_id:references:usersaborts the generator ("same name as the scope schema key"); the scope already addsfield :user_idand the migration column. - Keep the generated ownership checks.
update_*,delete_*andchange_*all start withtrue = post.user_id == scope.user.id. A hand-written "new" form must build%Post{user_id: scope.user.id}, orchange_post/2raisesMatchError. - Extend the one
Scopestruct; don't add a second assign. Add fields (org,org_role) plus a builder (put_org/3), and a second config entry that points at the samemoduleandassign_keywith a differentaccess_pathandschema_key. - Fill extra scope fields with a plug and an
on_mounthook. The plug covers the HTTP render and controllers; the hook covers the websocket mount and live navigation. Both look up membership through the current scope and never trust the slug alone. - Write the test helpers before generating against a new entry. Generated tests call
<name>_scope_fixture/0fromtest_data_fixtureandsetup :<test_setup_helper>. Both must exist, and the setup helper must returnscope:. - Scoped controllers need a pipeline that assigns the scope.
phx.gen.jsonoutput readsconn.assigns.current_scope, but the generated:apipipeline never sets it, so every action fails withKeyError. - Pass the scope down; never rebuild it from params. Contexts pass it to other contexts. Background jobs store ids and rebuild it in
perform/1. - Adopt scopes in a pre-1.8 app in this order: Scope module, then config, then assign
:current_scope, then test helpers, then owner columns, then generate or convert contexts one at a time.
The Two Pieces phx.gen.auth Adds
lib/my_app/accounts/scope.ex is a plain struct with for_user/1 (nil in gives nil out; guests have no scope). Its moduledoc asks you to extend it. The config entry tells every later generator how to use it:
config :my_app, :scopes,
user: [
default: true,
module: MyApp.Accounts.Scope,
assign_key: :current_scope,
access_path: [:user, :id],
schema_key: :user_id,
schema_type: :id,
schema_table: :users,
test_data_fixture: MyApp.AccountsFixtures,
test_setup_helper: :register_and_log_in_user
]
Keys accepted by Mix.Phoenix.Scope (an unknown key raises):
| Key | Drives |
|---|---|
| default | used when no flag is given; only one entry may be true (more raises) |
| module | the struct pattern-matched in context heads (%Scope{} = scope) |
| assign_key | socket.assigns.current_scope / conn.assigns.current_scope in generated web code |
| access_path | the owner value: [:user, :id] becomes scope.user.id in queries, put_change and topics |
| schema_key | the owner field added to the schema and migration (:user_id) |
| schema_type | that field's type (:id, :binary_id) |
| schema_table | references(:users, on_delete: :delete_all); nil gives a plain column with no FK |
| schema_migration_type | overrides the migration column type (defaults to schema_type unless it is :id) |
| route_prefix | e.g. "/orgs/:org_slug", prepended to every generated path; at most one :param |
| route_access_path | what fills that param, e.g. [:org, :slug] (defaults to access_path minus its last element) |
| test_data_fixture | the module generated tests import <name>_scope_fixture/0 from |
| test_setup_helper | the setup :helper in generated LiveView and controller tests |
The entry name (user, org) is also the PubSub topic prefix ("user:#{key}:posts") and the changeset's argument name (user_scope). The config must be in config/config.exs (or a file it imports). Generators never load runtime.exs.
phx.gen.auth takes --scope name and --assign-key key to rename its entry. It marks the entry default: true only if no default exists yet.
Running the Generators
| Command | Result |
|---|---|
| mix phx.gen.live Blog Post posts title:string | default scope |
| mix phx.gen.live Projects Project projects name:string --scope org | the org entry |
| mix phx.gen.html Pages Page pages title:string --no-scope | unscoped |
| --scope missing | ** (Mix) Scope :missing not configured! |
phx.gen.live, phx.gen.html, phx.gen.json, phx.gen.context and phx.gen.schema all take --scope / --no-scope.
A scoped run changes these files:
- Schema:
field :user_id, :id(notbelongs_to) andchangeset(post, attrs, user_scope), which casts only your fields and ends withput_change(:user_id, user_scope.user.id). - Migration:
add :user_id, references(:users, on_delete: :delete_all)andcreate index(:posts, [:user_id]). - Context: every function takes
%Scope{}first.list_*usesRepo.all_by(Post, user_id: ...)andget_*!usesRepo.get_by!(Post, id: id, user_id: ...). Writes assert ownership and broadcast{:created | :updated | :deleted, post}on"user:#{scope.user.id}:posts"(the full module is in thephoenix-pubsub-patternsskill). - LiveViews and controllers: every context call passes
socket.assigns.current_scope, and layouts getcurrent_scope={@current_scope}. The router hint ends with "Ensure the routes are defined in a block that sets the:current_scopeassign". - Tests and fixtures:
post_fixture(scope, attrs \\ %{})andsetup :register_and_log_in_user. Context tests create posts under twouser_scope_fixture()scopes and assert thatget_post!(other_scope, id)raisesEcto.NoResultsError.
Put generated LiveView routes in the existing authenticated live_session (see the phoenix-liveview-auth skill).
Adding an Org to the Scope
The plumbing for org-owned data. Roles, policies and who may do what are covered in the phoenix-authorization-patterns skill.
1. Struct and builder
defmodule MyApp.Accounts.Scope do
alias MyApp.Accounts.User
alias MyApp.Orgs.Org
defstruct user: nil, org: nil, org_role: nil
def for_user(%User{} = user), do: %__MODULE__{user: user}
def for_user(nil), do: nil
def put_org(%__MODULE__{} = scope, %Org{} = org, role) do
%{scope | org: org, org_role: role}
end
end
2. A second config entry for the generators
config :my_app, :scopes,
user: [
default: true,
# ... unchanged
],
org: [
module: MyApp.Accounts.Scope,
assign_key: :current_scope,
access_path: [:org, :id],
route_prefix: "/orgs/:org_slug",
route_access_path: [:org, :slug],
schema_key: :org_id,
schema_type: :id,
schema_table: :orgs,
test_data_fixture: MyApp.AccountsFixtures,
test_setup_helper: :register_and_log_in_user_with_org
]
3. Fill it: plug + on_mount
defmodule MyAppWeb.OrgScope do
use MyAppWeb, :verified_routes
import Plug.Conn
alias MyApp.Accounts.Scope
alias MyApp.Orgs
# Plug: the HTTP render and controllers
def assign_org(conn, _opts) do
scope = conn.assigns.current_scope
# {org, role} if the scope's user is a member, else nil
case Orgs.get_membership(scope, conn.params["org_slug"]) do
{org, role} ->
assign(conn, :current_scope, Scope.put_org(scope, org, role))
nil ->
conn
|> Phoenix.Controller.put_flash(:error, "Organization not found.")
|> Phoenix.Controller.redirect(to: ~p"/")
|> halt()
end
end
# on_mount: the websocket mount and each live navigation into the session
def on_mount(:assign_org, %{"org_slug" => slug}, _session, socket) do
scope = socket.assigns.current_scope
case Orgs.get_membership(scope, slug) do
{org, role} ->
{:cont, Phoenix.Component.assign(socket, :current_scope, Scope.put_org(scope, org, role))}
nil ->
{:halt,
socket
|> Phoenix.LiveView.put_flash(:error, "Organization not found.")
|> Phoenix.LiveView.redirect(to: ~p"/")}
end
end
end
# router.ex — add `import MyAppWeb.OrgScope, only: [assign_org: 2]`
scope "/orgs/:org_slug", MyAppWeb do
pipe_through [:browser, :require_authenticated_user, :assign_org]
live_session :require_org_member,
on_mount: [{MyAppWeb.UserAuth, :require_authenticated}, {MyAppWeb.OrgScope, :assign_org}] do
live "/projects", ProjectLive.Index, :index
live "/projects/new", ProjectLive.Form, :new
live "/projects/:id", ProjectLive.Show, :show
live "/projects/:id/edit", ProjectLive.Form, :edit
end
end
The plug runs after :require_authenticated_user, so current_scope.user is always set. The hook runs on every mount, but a push_patch to another org's URL inside the same LiveView does not re-run it. Generated code never patches across orgs; if yours does, re-check the org in handle_params/3.
4. Test helpers (before generating)
# test/support/fixtures/accounts_fixtures.ex
def org_scope_fixture, do: org_scope_fixture(user_scope_fixture())
def org_scope_fixture(%Scope{} = user_scope) do
n = System.unique_integer([:positive])
{:ok, org} = MyApp.Orgs.create_org_for(user_scope, %{name: "Org #{n}", slug: "org-#{n}"})
Scope.put_org(user_scope, org, :admin)
end
# test/support/conn_case.ex
def register_and_log_in_user_with_org(context) do
%{scope: scope} = context = register_and_log_in_user(context)
Map.put(context, :scope, MyApp.AccountsFixtures.org_scope_fixture(scope))
end
5. Generate
mix phx.gen.live Projects Project projects name:string --scope org produces:
field :org_id, :idandput_change(:org_id, org_scope.org.id);- the topic
"org:#{scope.org.id}:projects", shared by every member; - routes
~p"/orgs/#{@current_scope.org.slug}/projects"; - the new form building
%Project{org_id: socket.assigns.current_scope.org.id}; - tests using
org_scope_fixture()andsetup :register_and_log_in_user_with_org.
It stores no user_id, because the org owns the row. To record the author as well, add a non-cast field :created_by_id, :id and put_change(:created_by_id, scope.user.id) in the changeset.
Calling an org-scoped function with a user-only scope crashes deep inside with BadMapError (scope.org is nil). For a clear failure at the call site, tighten the head to %Scope{org: %Org{}} = scope.
Threading the Scope
- Hand-written context functions follow the generated shape: scope first, owner filter in the query, ownership assert before writes. The rules are in the
ecto-essentialsskill. - Context calling context: pass the same scope (
Billing.charge(scope, ...)), neverscope.useror a bare id. - Background jobs: a scope isn't JSON. Enqueue ids and rebuild the scope in the worker.
@impl Oban.Worker
def perform(%Oban.Job{args: %{"user_id" => user_id, "post_id" => post_id}}) do
scope = MyApp.Accounts.Scope.for_user(MyApp.Accounts.get_user!(user_id))
post = MyApp.Blog.get_post!(scope, post_id)
MyApp.Blog.publish_post(scope, post)
end
Scoped JSON Endpoints
The generated JSON controller test uses setup :register_and_log_in_user, which logs in through the session. Behind a Bearer-token pipeline, those tests fail until you swap that setup for a helper that creates a token and sets the authorization header. The token plug that assigns :current_scope is in the phoenix-json-api skill.
Adopting Scopes in a Pre-1.8 App
A 1.7 phx.gen.auth app assigns :current_user (fetch_current_user, on_mount :mount_current_user, :ensure_authenticated), and its register_and_log_in_user returns no scope. After bumping to Phoenix 1.8:
- Scope module. Copy the 1.8
accounts/scope.ex(above, without the org fields). - Config. Add the
:scopesentry shown at the top (default: true, your fixture module, your setup helper name). - Assign
:current_scopenext to:current_user. Keep both until every caller has moved:
def fetch_current_user(conn, _opts) do
{user_token, conn} = ensure_user_token(conn)
user = user_token && Accounts.get_user_by_session_token(user_token)
conn
|> assign(:current_user, user)
|> assign(:current_scope, Scope.for_user(user))
end
defp mount_current_user(socket, session) do
socket
|> Phoenix.Component.assign_new(:current_user, fn ->
if user_token = session["user_token"] do
Accounts.get_user_by_session_token(user_token)
end
end)
|> Phoenix.Component.assign_new(:current_scope, fn %{current_user: user} ->
Scope.for_user(user)
end)
end
- Test helpers. Add
user_scope_fixture/0toAccountsFixtures, and haveregister_and_log_in_user/1returnscope: Scope.for_user(user)too. - Owner columns. Add
user_id(FK and index) to existing tables, backfill it, then make itNOT NULLbefore scoping their contexts. On tables with live traffic, follow the steps in theecto-migrations-safetyskill. - Convert contexts one at a time. Add the scope argument and the owner filter. The arity change makes the compiler list every caller you still have to fix.
- Web layer. 1.8 templates render
<Layouts.app flash={@flash} current_scope={@current_scope}>and use 1.8 components (<.button variant="primary">). A 1.7 app has neither: itsapp.html.heexis a layout using{@inner_content}, and itsbutton/1has no:variant. Port the 1.8Layouts.app/1andcore_components.exfirst, or generate into a scratch 1.8 app and copy over only the context, schema, migration and tests.
Common Mistakes
<!-- epg:bad --># Bad — unscoped calls and a global topic in a scoped app
posts = Blog.list_posts()
post = Repo.get!(Post, id)
Phoenix.PubSub.broadcast(MyApp.PubSub, "posts", {:updated, post})
def update_post(%Scope{} = _scope, %Post{} = post, attrs) do
post |> Post.changeset(attrs) |> Repo.update()
end
# Good — scope in, owner filter in the query, ownership asserted, scoped topic
posts = Blog.list_posts(scope)
post = Blog.get_post!(scope, id)
def update_post(%Scope{} = scope, %Post{} = post, attrs) do
true = post.user_id == scope.user.id
with {:ok, post = %Post{}} <- post |> Post.changeset(attrs, scope) |> Repo.update() do
broadcast_post(scope, {:updated, post})
{:ok, post}
end
end
| Symptom | Cause |
|---|---|
| Generated context has no scope argument | no default :scopes entry, or --no-scope |
| KeyError :current_scope in a generated controller | its pipeline doesn't assign the scope (:api) |
| undefined function org_scope_fixture/0 in generated tests | the helper for the new entry wasn't written first |
| MatchError opening a "new" form | struct built without the owner (%Post{} instead of %Post{user_id: ...}) |
| Another user's records appear live | a global topic, or a hand-written broadcast that skips broadcast_* |
| There can only be one default scope | two entries have default: true (often a copy-pasted user entry) |
See the testing-essentials skill for the generated fixtures in tests, and the phoenix-authorization-patterns skill for policies and roles on top of the scope.