code-quality
Use when refactoring or reviewing Elixir code quality — compiler warnings and Credo first, then the plugin's on-demand…
Use when writing Ecto changeset validations beyond cast/validate_required — custom and cross-field validations (e.g. end date after start date), separate changesets per operation (registration vs update), conditional validation, field transforms, password/email rules, uniqueness
$ npx -y skills add j-morgan6/elixir-phoenix-guide --skill ecto-changeset-patterns --agent claude-codeHow it fires
How this skill gets triggered: by you, by Claude, or both.
/ecto-changeset-patternsContext preview
The summary Claude sees to decide when to auto-load this skill.
Use when writing Ecto changeset validations beyond cast/validate_required — custom and cross-field validations (e.g. end date after start date), separate changesets per operation (registration vs update), conditional validation, field transforms, password/email rules, uniqueness
name: ecto-changeset-patterns description: "Use when writing Ecto changeset validations beyond cast/validate_required — custom and cross-field validations (e.g. end date after start date), separate changesets per operation (registration vs update), conditional validation, field transforms, password/email rules, uniqueness checks. Not for parent/child forms or cast_assoc (use ecto-nested-associations)."
> Verified against Elixir 1.20, Phoenix 1.8, Ecto 3.14 — October 2026
1. **Create separate named changesets per operation** — `email_changeset`, `password_changeset`, `profile_changeset`; never one `changeset/2` that casts everything 2. **Never `cast` foreign keys or privileged fields** (`:user_id`, `:org_id`, `:role`, `:confirmed_at`, `:deleted_at`) — set them with `put_change`/`change` from trusted code after a scope-checked lookup; for owned records use the Phoenix 1.8 `changeset(struct, attrs, scope)` shape 3. **Compose changesets with pipes** — each validation step is a small private function reused across changesets 4. **Pair `unsafe_validate_unique` with `unique_constraint`** — the first gives live form feedback, the second catches the race; both are case-sensitive unless the column is `citext` 5. **Use `update_change/3` for normalisation** (trim, downcase, slug) — in the changeset, never in the controller or LiveView; call it after `validate_required` 6. **Accept `opts \\ []` to skip expensive steps during live validation** — phx.gen.auth's `validate_unique: false` (no DB query per keystroke) and `hash_password: false` (no Bcrypt per keystroke) 7. **Passwords: `min: 12, max: 72`, plus `max: 72, count: :bytes` before hashing** — Bcrypt only reads the first 72 bytes, and multibyte characters pass a character-count check 8. **Validate in the changeset, not in context functions** — contexts stay thin wrappers around `Repo` calls
---
The Phoenix 1.8 `phx.gen.auth` `User` schema already works this way. Here it is with a `role` added:
defmodule MyApp.Accounts.User do
use Ecto.Schema
import Ecto.Changeset
schema "users" do
field :email, :string
field :role, Ecto.Enum, values: [:member, :admin], default: :member
field :password, :string, virtual: true, redact: true
field :hashed_password, :string, redact: true
field :confirmed_at, :utc_datetime
field :authenticated_at, :utc_datetime, virtual: true
timestamps(type: :utc_datetime)
end
# Generated. 1.8 registration is email-only (magic link) and register_user/1 uses
# this changeset; to register extra fields, add a registration_changeset/3 that
# pipes email_changeset/3 (see the phoenix-auth-customization skill).
def email_changeset(user, attrs, opts \\ []) do
user
|> cast(attrs, [:email])
|> validate_email(opts)
end
# Generated
def password_changeset(user, attrs, opts \\ []) do
user
|> cast(attrs, [:password])
|> validate_confirmation(:password, message: "does not match password")
|> validate_password(opts)
end
# Privileged fields are never in a cast list. Trusted code sets them explicitly.
# confirm_changeset/1 is generated; role_changeset/2 is yours.
def confirm_changeset(user), do: change(user, confirmed_at: DateTime.utc_now(:second))
def role_changeset(user, role) when role in [:member, :admin], do: change(user, role: role)
endUser-editable extras (username, profile fields) belong in their own changesets, `registration_changeset/3` and `profile_changeset/2` in the `phoenix-auth-customization` skill. If such a changeset cast `:role` or `:confirmed_at`, any user could promote or confirm themselves by adding a form field. Call `role_changeset/2` only from an admin-only context function.
For records owned by a user, the 1.8 generators take the scope as the third argument and set the owner themselves:
def changeset(post, attrs, user_scope) do post |> cast(attrs, [:title, :body]) |> validate_required([:title, :body]) |> put_change(:user_id, user_scope.user.id) end
Mass assignment: anything in a `cast` list can be set from a form or JSON body.
<!-- epg:bad -->
# A user can post user_id of another account, or folder_id of someone else's folder |> cast(attrs, [:title, :folder_id, :user_id])
A foreign key the user chooses (`folder_id`) is set from a record loaded through the scope: `folder = get_folder!(scope, folder_id)`, then `put_change(:folder_id, folder.id)` (see `move_image/3` in the `ecto-essentials` skill).
---
The private validators `phx.gen.auth` generates for the changesets above:
defp validate_email(changeset, opts) do
changeset =
changeset
|> validate_required([:email])
|> validate_format(:email, ~r/^[^@,;\s]+@[^@,;\s]+$/,
message: "must have the @ sign and no spaces"
)
|> validate_length(:email, max: 160)
if Keyword.get(opts, :validate_unique, true) do
changeset
|> unsafe_validate_unique(:email, MyApp.Repo)
|> unique_constraint(:email)
|> validate_email_changed()
else
changeset
end
end
defp validate_email_changed(changeset) do
if get_field(changeset, :email) && get_change(changeset, :email) == nil do
add_error(changeset, :email, "did not change")
else
changeset
end
end
defp validate_password(changeset, opts) do
changeset
|> validate_required([:password])
|> validate_length(:password, min: 12, max: 72)
|> maybe_hash_password(opts)
end
defp maybe_hash_password(changeset, opts) do
hash_password? = Keyword.get(opts, :hash_password, true)
password = get_change(changeset, :password)
if hash_password? && password && changeset.valid? do
changeset
# Bcrypt truncates at 72 bytes; "é" is 2 bytes, so 72 characters can exceed it
|> validate_length(:password, max: 72, count: :bytes)
|> put_change(:hashed_password, Bcrypt.hash_pwd_salt(passwo· Changelog A Claude Code plugin (with Codex support for the skills) that keeps Claude's Elixir and Phoenix code idiomatic and current.
Use when refactoring or reviewing Elixir code quality — compiler warnings and Credo first, then the plugin's on-demand…
Use when preparing releases or deployment config — mix phx.gen.release, runtime.exs vs compile-time config, release migrations,…
Use when defining schemas, writing queries, or creating migrations — scoped context functions, Repo.transact, upserts, indexes,…
Use when migrating PostgreSQL tables that serve live traffic — index concurrently,…
Use when one form or operation manages parent and child records together — cast_assoc/cast_embed, inputs_for with sort/drop…
Use when a query is slow, N+1 or hard to compose — preload vs join-preload, dynamic/2, subquery/exists, Repo.aggregate,…