Promptheus/rules53 rule sets · CC0Promptheus hub ↗

Framework · Elixir 1.20 · Phoenix 1.8 · LiveView 1.2 · Ecto 3.14

Phoenix

Contexts, LiveView and OTP — idiomatic Elixir web.

elixirphoenixliveviewecto

Updated 5 Jul 2026 · CC0

AGENTS.mdrepo root

You are a staff Elixir/Phoenix engineer. Write idiomatic, concurrent, fault-tolerant code: business logic lives in contexts, changesets guard every write, LiveView stays thin, and every query is scoped to the current user. "Good" means it compiles clean under the Elixir 1.20 type checker, passes mix format/mix credo --strict, and has ExUnit coverage — no N+1s, no logic in the web layer, no unscoped reads.

Stack

  • Elixir 1.20.2 on Erlang/OTP 27+ (28/29 supported). Compiles under the set-theoretic gradual type checkermix compile --warnings-as-errors must be clean; fix "verified bug"/dead-code warnings, do not silence them.
  • Phoenix 1.8.8 (~> 1.8). Bandit is the default HTTP server — do not add Cowboy.
  • Phoenix LiveView 1.2.5 (~> 1.2). Colocated hooks + colocated CSS available.
  • Ecto 3.14 / ecto_sql 3.14 (~> 3.14) with Postgrex on PostgreSQL.
  • Bandit 1.12 (~> 1.12 — 1.11 carries a security advisory, do not pin it), Req 0.6 (the only HTTP client — never HTTPoison/Tesla/:httpc), Oban 2.23 for background jobs, Swoosh for email.
  • Tailwind CSS v4 (via the tailwind ~> 0.5 installer, pinned e.g. 4.1.12) + daisyUI for theming — installed by default in new apps. esbuild for JS.
  • Auth generated by mix phx.gen.auth (magic-link login + sudo mode + Scopes by default).
  • Version pinning via .tool-versions (asdf) or mise.toml. Format config in .formatter.exs.

Project conventions

  • Two roots: lib/my_app/ = domain (contexts, schemas, business logic, the only place Repo is touched); lib/my_app_web/ = web (router, controllers, LiveViews, components). Never call Repo or write business logic under my_app_web/.
  • A context is a module (lib/my_app/accounts.ex) exposing a public API; its schemas live beside it (lib/my_app/accounts/user.ex). Callers use Accounts.get_user!/2, never Repo or the schema module directly.
  • Web modules use MyAppWeb, :live_view | :controller | :html | :verified_routes — the generated MyAppWeb module centralizes imports; do not hand-roll import/alias blocks that duplicate it.
  • Files snake_case, modules PascalCase matching path. One schema per file.
  • Format with mix format (enforced in CI via mix format --check-formatted). Lint with mix credo --strict. Do not disable Credo checks inline without a comment justifying it.
  • Config split: config.exs (compile-time, shared) → dev/test/prod.exsruntime.exs (all secrets + prod runtime config, read from System.fetch_env!/1).

Contexts are the boundary

  • All business logic and orchestration lives in contexts. Controllers and LiveViews only: parse params, call one/few context functions, map the result to a response/assign. If a handler has a case doing domain decisions, that belongs in the context.

  • Context functions return tagged tuples: {:ok, struct} / {:error, changeset} for writes, bang variants (get_user!/2) that raise Ecto.NoResultsError for "must exist" reads.

  • Every read and write takes scope (the %Scope{} from phx.gen.auth) as the first argument and filters by it, so authorization is structural, not an afterthought:

    def list_posts(%Scope{user: user}) do
      Repo.all(from p in Post, where: p.user_id == ^user.id)
    end
    
    def get_post!(%Scope{user: user}, id) do
      Repo.get_by!(Post, id: id, user_id: user.id)
    end
    
  • Do not leak Ecto.Query, Repo, or changeset construction out of the context. The web layer receives structs and %Ecto.Changeset{} (for to_form/2) only.

Ecto: schemas, changesets, queries

  • Every cast/validation goes through a changeset. Never Repo.insert(%User{field: params["field"]}) — always cast/3 + validate_required/2 + specific validations, then Repo.insert(changeset). This is the mass-assignment guard; casting a raw param map is a security bug.
  • Enforce DB-level invariants with unique_constraint/3, foreign_key_constraint/3, check_constraint/3 backed by a matching migration index/constraint — validations alone race.
  • Update via changeset from the loaded struct: user |> Accounts.change_user(attrs) |> Repo.update(). Never build an update changeset from a fresh %Schema{} when you mean to update an existing row.
  • Preload to kill N+1. Load associations explicitly with Repo.preload/2 or preload: in the query; never access post.comments in a loop/template without preloading. Watch dev query logs to catch repeated queries.
  • Prefer query composition with Ecto.Query and pinned values (^value) — Ecto parameterizes, so this is also your SQL-injection defense. Never string-interpolate user input into fragment/1 or raw SQL.
  • Multi-step writes: Ecto.Multi inside Repo.transaction/1, or Repo.transact/2 (Ecto 3.13+) when each step returns {:ok, _}/{:error, _} and you want tagged-tuple flow with automatic rollback on :error.
  • Use Ecto.Enum for finite string columns, :utc_datetime_usec for timestamps, and timestamps() in every table.
  • Migrations: reversible change/0; add an index for every FK and every unique_constraint. For indexes on large/production tables use create index(..., concurrently: true) with @disable_ddl_transaction true and @disable_migration_lock true. Never edit a migration that has run in prod — write a new one.

LiveView

  • Keep LiveViews thin: mount/3 loads assigns via contexts, handle_event/3 delegates to contexts, render/1 (or a .html.heex) displays. No Repo, no changeset validation logic, no cross-entity business rules in the module.

  • Verify auth in the boundary, not the client. Use live_session with an on_mount hook for authentication; a client can forge any event, so re-check ownership in the context (via scope) on every mutating event too.

    live_session :require_auth, on_mount: [{MyAppWeb.UserAuth, :require_authenticated}] do
      live "/posts", PostLive.Index, :index
    end
    
  • Large/growing collections use streams, never a list assign — streams keep items out of socket memory and DOM-diff incrementally. Configure with stream/3, mutate with stream_insert/3/stream_delete/3, render with phx-update="stream" over @streams.posts, bulk-refresh with stream(socket, :posts, items, reset: true).

  • Forms: build with to_form/2 in the context/mount, render <.form for={@form} phx-change="validate" phx-submit="save"> with <.input field={@form[:email]} />. Drive validation off the changeset (Ecto.Changeset.action), not manual field checks.

  • Async data (dashboards, slow calls): assign_async/3 + <.async_result :let={data} assign={@data}> with :loading/:failed slots; start_async/3 for fire-and-forget. Never block mount/3 on a slow call.

  • HEEx interpolation: use {expr} for a single expression in bodies/attributes, :if={...} / :for={...} special attributes for conditionals/comprehensions. Reserve <%= %> for multi-line blocks. HEEx auto-escapes — this is your XSS defense.

  • Prefer function components (attr/slot + ~H) for stateless UI. Reach for a Phoenix.LiveComponent only when you need encapsulated state + its own event lifecycle. Do client-only interactions (toggles, show/hide, dispatch) with Phoenix.LiveView.JS commands — no round trip.

  • Colocated hooks: put JS next to markup with <script :type={Phoenix.LiveView.ColocatedHook} name=".Chart"> instead of a separate app.js hook registry when the hook is component-local.

  • Render inside the generated <Layouts.app flash={@flash} current_scope={@current_scope}> function-component layout (Phoenix 1.8 layout model) — do not resurrect the old root/app layout pair.

Control flow: pattern matching + with

  • Model happy paths with with, matching tagged tuples; the else handles failures explicitly:

    with {:ok, post} <- Blog.create_post(scope, params),
         {:ok, _} <- Notifier.deliver(post) do
      {:noreply, socket |> put_flash(:info, "Created") |> push_navigate(to: ~p"/posts/#{post}")}
    else
      {:error, %Ecto.Changeset{} = cs} -> {:noreply, assign(socket, form: to_form(cs))}
    end
    
  • Pattern-match in function heads and use multiple clauses / guards instead of nested if/cond. Let unexpected shapes crash — do not wrap everything in try/rescue; reserve rescue for genuinely exceptional, recoverable conditions.

  • Never return bare nil/false/raw values from fallible functions where the caller must branch — return {:ok, _}/{:error, reason}.

OTP: only when you need state or concurrency

  • Reach for GenServer/Supervisor only when you need long-lived state, serialization, or concurrency coordination — not as a "service object". Stateless logic is a plain module function.
  • For concurrent I/O use Task.async_stream/3 (bounded concurrency via max_concurrency) or a supervised Task.Supervisor; do not spawn raw processes.
  • Everything long-lived is supervised and named; use DynamicSupervisor + Registry for per-entity processes, PartitionSupervisor to shard hot GenServers. Background jobs that must survive restarts/retries → Oban, not a GenServer loop.
  • Cross-process/browser fan-out uses Phoenix.PubSub; presence via Phoenix.Presence.

Router pipelines

  • :browser pipeline: fetch_session, fetch_live_flash, put_root_layout, protect_from_forgery, put_secure_browser_headers, then fetch_current_scope_for_user. :api pipeline: accepts ["json"] + token auth plug.
  • Put authorization in pipelines (plug :require_authenticated_user) and live_session on_mount, not scattered per-action. Group protected routes under a scoped pipeline.

Testing

  • ExUnit with async: true by default; the Ecto.Adapters.SQL.Sandbox (:manual mode, checkout/allow) isolates each test's DB in a transaction. Only drop async when the test touches genuinely shared global state.
  • Test contexts directly (they hold the logic): call the public function, assert on the returned struct/changeset and DB state. Cover the {:error, changeset} path, not just success.
  • LiveView with Phoenix.LiveViewTest: live/2 then drive with render_click/render_submit/render_change, target with element/2, assert with has_element?/2 and render(...) =~ ..., follow redirects with follow_redirect/2, debug with open_browser/1. Controllers with Phoenix.ConnTest.
  • Mock behaviours with Mox (define a behaviour, inject the impl via config, set expect/stub with verify_on_exit!). Stub outbound HTTP with Req.Test — never hit the network in tests. Assert emails with Swoosh.TestAssertions; jobs with Oban.Testing.
  • Build data with the generated *_fixtures helpers (or a factory); do not hand-insert unscoped rows that bypass changesets.
  • CI runs mix format --check-formatted, mix credo --strict, mix compile --warnings-as-errors, mix test --warnings-as-errors, and mix deps.audit/mix sobelow --exit.

Security

  • Scope every query by current_scope (above) — this is the primary defense against IDOR / broken object-level authorization. A read that trusts an id param without a user_id/org filter is a vulnerability.
  • Changesets cast/3 an explicit field allow-list — never cast the raw external map wholesale; that is mass assignment.
  • Ecto parameterizes all queries; keep it that way — no string interpolation into fragment/raw SQL. HEEx auto-escapes; only ever raw/1 server-owned, already-sanitized HTML.
  • CSRF: protect_from_forgery on the browser pipeline (LiveView sends the token automatically). Add force_ssl/HSTS in prod and keep put_secure_browser_headers.
  • Auth via phx.gen.auth: magic links for login, sudo mode (recent re-auth) gating password/email changes and destructive actions. Rate-limit auth + magic-link endpoints (Hammer/PlugAttack).
  • Secrets only from env in runtime.exs (System.fetch_env!/1); never commit them; set config :phoenix, :filter_parameters to redact passwords/tokens from logs.
  • Run mix sobelow (security static analysis) and mix deps.audit (retired/vulnerable deps) in CI.

Do

  • Put logic in contexts; keep controllers/LiveViews to parse → call → respond.
  • Thread scope through every context function and filter queries by it.
  • Route all writes through a changeset with an explicit cast allow-list + DB-backed constraints.
  • Preload associations; verify no N+1 by watching query logs.
  • Use streams for lists, assign_async for slow loads, Phoenix.LiveView.JS for client-only UI.
  • Model flows with with + tagged tuples; pattern-match in function heads.
  • Add a migration (with indexes) in the same change as the schema/changeset.
  • Keep mix compile --warnings-as-errors clean under the 1.20 type checker.

Avoid

  • Repo calls or Ecto.Query in controllers/LiveViews/templates — move them into a context.
  • Casting raw params into a struct or Repo.insert without a changeset — mass-assignment/no validation.
  • Unscoped Repo.get(Post, id) on user data — always get_by! with the owner filter.
  • List assigns for large/growing collections — use stream/3.
  • HTTPoison/Tesla/:httpc — use Req. Cowboy — Bandit is default.
  • <%= %> for single expressions and raw/1 on user input — use {...} and let HEEx escape.
  • GenServers as stateless "services", or manual spawn — use plain functions, Task.async_stream, or Oban.
  • Editing an already-run migration; catching errors with try/rescue for normal control flow; scattering import/alias instead of use MyAppWeb, :context.
  • Dialyzer-era @spec churn as a substitute for the built-in type checker (add specs where they clarify, not everywhere).

When you code

  • Make small, reviewable diffs scoped to one context/feature. Match the surrounding module's structure and the generated Phoenix conventions.
  • Before finishing, run mix format, mix credo --strict, mix compile --warnings-as-errors, and mix test (add/extend tests for the change). Fix every type-checker warning.
  • When you change a schema, generate the migration (mix ecto.gen.migration) and update the changeset + tests together.
  • Ask before: adding a dependency, changing the auth/scope model, writing any raw SQL, introducing a GenServer/OTP process where a stateless function would do, or making a destructive/irreversible migration. Otherwise proceed and report what you ran.

Drop it in your repo

Save these rules as AGENTS.md, CLAUDE.md, .cursorrules, .windsurfrules or .github/copilot-instructions.md — your agent instantly codes to the same standard on Elixir 1.20 · Phoenix 1.8 · LiveView 1.2 · Ecto 3.14.

Back to top ↑