From elixir-phoenix-guide
Enforces Ecto changeset best practices: separate changesets per operation, cast_assoc without foreign keys, pipe composition, unsafe_validate_unique with unique_constraint, field transformations, conditional opts. For complex CRUD.
npx claudepluginhub j-morgan6/elixir-phoenix-guide --plugin elixir-phoenix-guideThis skill uses the workspace's default tool permissions.
1. **Create separate named changesets per operation** — `registration_changeset`, `email_changeset`, `password_changeset`; never overload a single `changeset/2`
Searches, retrieves, and installs Agent Skills from prompts.chat registry using MCP tools like search_skills and get_skill. Activates for finding skills, browsing catalogs, or extending Claude.
Searches prompts.chat for AI prompt templates by keyword or category, retrieves by ID with variable handling, and improves prompts via AI. Use for discovering or enhancing prompts.
Checks Next.js compilation errors using a running Turbopack dev server after code edits. Fixes actionable issues before reporting complete. Replaces `next build`.
registration_changeset, email_changeset, password_changeset; never overload a single changeset/2cast_assoc child changesets — the parent sets them automatically; requiring them causes "can't be blank" errorsunsafe_validate_unique paired with unique_constraint — never one without the other; unsafe_validate_unique gives fast UI feedback, unique_constraint handles race conditionsupdate_change/3 for field transformations — trimming, downcasing, slugifying happen in the changeset, never in the controller or contextopts \\ [] for conditional validation — allows callers to toggle validation rules without creating yet another changeset functionRepo callsDifferent operations need different validation rules. Don't overload changeset/2.
defmodule MyApp.Accounts.User do
use Ecto.Schema
import Ecto.Changeset
schema "users" do
field :email, :string
field :username, :string
field :password, :string, virtual: true, redact: true
field :hashed_password, :string, redact: true
field :bio, :string
timestamps()
end
# Registration — all fields, password hashing
def registration_changeset(user, attrs, opts \\ []) do
user
|> cast(attrs, [:email, :username, :password])
|> validate_email(opts)
|> validate_username()
|> validate_password(opts)
end
# Email change — only email, requires reconfirmation
def email_changeset(user, attrs, opts \\ []) do
user
|> cast(attrs, [:email])
|> validate_email(opts)
end
# Password change — only password
def password_changeset(user, attrs, opts \\ []) do
user
|> cast(attrs, [:password])
|> validate_password(opts)
|> put_password_hash()
end
# Profile update — non-sensitive fields only
def profile_changeset(user, attrs) do
user
|> cast(attrs, [:username, :bio])
|> validate_username()
end
end
The most common source of "can't be blank" errors. Foreign keys are set automatically by the parent — never require them in the child changeset.
# Parent schema
defmodule MyApp.Blog.Post do
schema "posts" do
field :title, :string
has_many :ingredients, MyApp.Blog.Ingredient
timestamps()
end
def changeset(post, attrs) do
post
|> cast(attrs, [:title])
|> validate_required([:title])
|> cast_assoc(:ingredients, with: &MyApp.Blog.Ingredient.changeset/2)
end
end
# Child schema — DO NOT require :post_id
defmodule MyApp.Blog.Ingredient do
schema "ingredients" do
field :name, :string
field :quantity, :string
belongs_to :post, MyApp.Blog.Post
timestamps()
end
# Bad — :post_id is required but set automatically by cast_assoc
def changeset(ingredient, attrs) do
ingredient
|> cast(attrs, [:name, :quantity, :post_id])
|> validate_required([:name, :post_id]) # Fails!
end
# Good — only require user-provided fields
def changeset(ingredient, attrs) do
ingredient
|> cast(attrs, [:name, :quantity])
|> validate_required([:name])
end
end
Break validation into small, reusable functions. Compose with pipes.
defmodule MyApp.Accounts.User do
# Reusable validation components
defp validate_email(changeset, opts) do
changeset
|> validate_required([:email])
|> validate_format(:email, ~r/^[^\s]+@[^\s]+$/, message: "must have the @ sign and no spaces")
|> validate_length(:email, max: 160)
|> maybe_validate_unique_email(opts)
end
defp validate_username(changeset) do
changeset
|> validate_required([:username])
|> validate_format(:username, ~r/^[a-zA-Z0-9_]+$/, message: "only letters, numbers, and underscores")
|> validate_length(:username, min: 3, max: 30)
|> unsafe_validate_unique(:username, MyApp.Repo)
|> unique_constraint(:username)
end
defp validate_password(changeset, opts) do
changeset
|> validate_required([:password])
|> validate_length(:password, min: 8, max: 72)
|> maybe_hash_password(opts)
end
defp maybe_validate_unique_email(changeset, opts) do
if Keyword.get(opts, :validate_email, true) do
changeset
|> unsafe_validate_unique(:email, MyApp.Repo)
|> unique_constraint(:email)
else
changeset
end
end
defp maybe_hash_password(changeset, opts) do
if Keyword.get(opts, :hash_password, true) && changeset.valid? do
changeset
|> put_change(:hashed_password, Bcrypt.hash_pwd_salt(get_change(changeset, :password)))
|> delete_change(:password)
else
changeset
end
end
end
Use opts to toggle validation behavior from the caller. This avoids creating a new changeset function for every variation.
# In the schema module
def registration_changeset(user, attrs, opts \\ []) do
user
|> cast(attrs, [:email, :username, :password])
|> validate_email(opts)
|> validate_password(opts)
end
# In the context — normal registration
def register_user(attrs) do
%User{}
|> User.registration_changeset(attrs)
|> Repo.insert()
end
# In tests — skip hashing for speed
def register_user_for_test(attrs) do
%User{}
|> User.registration_changeset(attrs, hash_password: false, validate_email: false)
|> Repo.insert()
end
Transform field values in the changeset, not in the controller or LiveView.
def changeset(user, attrs) do
user
|> cast(attrs, [:email, :username])
|> update_change(:email, &String.downcase/1)
|> update_change(:username, &String.trim/1)
|> update_change(:username, &String.downcase/1)
end
# For slugs
def changeset(post, attrs) do
post
|> cast(attrs, [:title])
|> validate_required([:title])
|> generate_slug()
end
defp generate_slug(changeset) do
case get_change(changeset, :title) do
nil -> changeset
title ->
slug = title |> String.downcase() |> String.replace(~r/[^a-z0-9]+/, "-") |> String.trim("-")
put_change(changeset, :slug, slug)
end
end
Always pair unsafe_validate_unique with unique_constraint. They serve different purposes.
def changeset(user, attrs) do
user
|> cast(attrs, [:email, :username])
# Fast check — queries DB, gives immediate UI feedback
# "unsafe" because another insert could happen between check and insert
|> unsafe_validate_unique(:email, MyApp.Repo)
|> unsafe_validate_unique(:username, MyApp.Repo)
# Constraint check — catches race conditions at insert time
# Requires a matching unique index in the database
|> unique_constraint(:email)
|> unique_constraint(:username)
end
describe "registration_changeset/2" do
test "valid with all required fields" do
changeset = User.registration_changeset(%User{}, %{
email: "test@example.com",
username: "testuser",
password: "validpassword123"
})
assert changeset.valid?
end
test "invalid without email" do
changeset = User.registration_changeset(%User{}, %{
username: "testuser",
password: "validpassword123"
})
refute changeset.valid?
assert "can't be blank" in errors_on(changeset).email
end
test "transforms email to lowercase" do
changeset = User.email_changeset(%User{}, %{email: "TEST@Example.COM"})
assert get_change(changeset, :email) == "test@example.com"
end
end
See ecto-essentials skill for schema and migration patterns.
See ecto-nested-associations skill for cast_assoc with nested data.
See testing-essentials skill for comprehensive testing patterns.