From elixir-phoenix-guide
Guides Ecto best practices for nested associations: cast_assoc for has_many/has_one, Ecto.Multi for multi-table ops, preloads before updates, FK indexes, on_replace: :delete.
npx claudepluginhub j-morgan6/elixir-phoenix-guide --plugin elixir-phoenix-guideThis skill uses the workspace's default tool permissions.
1. **Use `cast_assoc/3` for has_many/has_one** — never manually insert children in a separate step; let Ecto manage the relationship
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`.
cast_assoc/3 for has_many/has_one — never manually insert children in a separate step; let Ecto manage the relationshipEcto.Multi for operations spanning multiple unrelated tables — not nested changesets; Multi provides explicit rollback controlon_delete explicitly in migrations — :delete_all for owned children, :nothing for references to independent entitieson_replace: :delete in cast_assoc for list management — allows removing items by omitting them from the inputcast_assoc compares against currently loaded data; unloaded associations cause silent data lossCreate parent and children in a single operation. Ecto sets foreign keys automatically.
# Schema definitions
defmodule MyApp.Blog.Post do
use Ecto.Schema
import Ecto.Changeset
schema "posts" do
field :title, :string
has_many :comments, MyApp.Blog.Comment
timestamps()
end
def changeset(post, attrs) do
post
|> cast(attrs, [:title])
|> validate_required([:title])
|> cast_assoc(:comments, with: &MyApp.Blog.Comment.changeset/2)
end
end
defmodule MyApp.Blog.Comment do
use Ecto.Schema
import Ecto.Changeset
schema "comments" do
field :body, :string
belongs_to :post, MyApp.Blog.Post
timestamps()
end
# Do NOT require :post_id — cast_assoc sets it automatically
def changeset(comment, attrs) do
comment
|> cast(attrs, [:body])
|> validate_required([:body])
end
end
# Usage — create post with comments in one operation
Blog.create_post(%{
title: "My Post",
comments: [
%{body: "First comment"},
%{body: "Second comment"}
]
})
When updating a has_many, on_replace: :delete removes children that are omitted from the input.
defmodule MyApp.Recipes.Recipe do
schema "recipes" do
field :name, :string
has_many :ingredients, MyApp.Recipes.Ingredient, on_replace: :delete
timestamps()
end
def changeset(recipe, attrs) do
recipe
|> cast(attrs, [:name])
|> validate_required([:name])
|> cast_assoc(:ingredients, with: &MyApp.Recipes.Ingredient.changeset/2)
end
end
# Update — send the full list; omitted items are deleted
def update_recipe(recipe, attrs) do
recipe
|> Repo.preload(:ingredients) # MUST preload before cast_assoc
|> Recipe.changeset(attrs)
|> Repo.update()
end
# Example: recipe has ingredients A, B, C
# Sending %{ingredients: [%{id: a.id, name: "A"}, %{name: "D"}]}
# Result: A is updated, B and C are deleted, D is created
# Bad — ingredients not preloaded, cast_assoc can't compare
recipe = Repo.get!(Recipe, id)
Recipe.changeset(recipe, attrs) # ingredients is %Ecto.Association.NotLoaded{}
|> Repo.update() # Silently ignores association changes!
# Good — preload before updating
recipe = Repo.get!(Recipe, id) |> Repo.preload(:ingredients)
Recipe.changeset(recipe, attrs) # ingredients is [%Ingredient{}, ...]
|> Repo.update() # Correctly diffs and applies changes
Use cast_embed for data stored as JSON in a single column (no separate table).
defmodule MyApp.Profiles.Profile do
use Ecto.Schema
import Ecto.Changeset
schema "profiles" do
field :name, :string
embeds_many :social_links, SocialLink, on_replace: :delete
timestamps()
end
def changeset(profile, attrs) do
profile
|> cast(attrs, [:name])
|> cast_embed(:social_links, with: &SocialLink.changeset/2)
end
end
defmodule MyApp.Profiles.Profile.SocialLink do
use Ecto.Schema
import Ecto.Changeset
@primary_key false
embedded_schema do
field :platform, :string
field :url, :string
end
def changeset(link, attrs) do
link
|> cast(attrs, [:platform, :url])
|> validate_required([:platform, :url])
|> validate_format(:url, ~r/^https?:\/\//)
end
end
When operations span unrelated tables or need explicit control over transaction steps:
defmodule MyApp.Orders do
alias Ecto.Multi
def place_order(user, cart_items) do
Multi.new()
|> Multi.insert(:order, build_order(user))
|> Multi.insert_all(:line_items, LineItem, fn %{order: order} ->
Enum.map(cart_items, fn item ->
%{
order_id: order.id,
product_id: item.product_id,
quantity: item.quantity,
price: item.price,
inserted_at: DateTime.utc_now(:second),
updated_at: DateTime.utc_now(:second)
}
end)
end)
|> Multi.update(:decrement_stock, fn %{order: _order} ->
decrement_stock_changeset(cart_items)
end)
|> Repo.transaction()
end
end
# Handling Multi results
case Orders.place_order(user, cart_items) do
{:ok, %{order: order, line_items: {count, _}, decrement_stock: _}} ->
# All operations succeeded
{:ok, order}
{:error, :order, changeset, _changes_so_far} ->
# Order insert failed — nothing committed
{:error, changeset}
{:error, :decrement_stock, changeset, _changes_so_far} ->
# Stock update failed — order and line items rolled back
{:error, changeset}
end
Multi.new()
|> Multi.run(:validate_stock, fn _repo, _changes ->
if sufficient_stock?(cart_items) do
{:ok, :valid}
else
{:error, :insufficient_stock}
end
end)
|> Multi.insert(:order, fn %{validate_stock: :valid} ->
build_order(user)
end)
|> Repo.transaction()
defmodule MyApp.Repo.Migrations.CreateComments do
use Ecto.Migration
def change do
create table(:comments) do
add :body, :text, null: false
# Child — cascade delete when parent is deleted
add :post_id, references(:posts, on_delete: :delete_all), null: false
# Reference — don't cascade (user deletion shouldn't delete comments)
add :user_id, references(:users, on_delete: :nothing), null: false
timestamps()
end
# Always index foreign keys
create index(:comments, [:post_id])
create index(:comments, [:user_id])
end
end
# :delete_all — child cannot exist without parent
add :comment_id, references(:comments, on_delete: :delete_all) # Reply → Comment
add :line_item_id, references(:orders, on_delete: :delete_all) # LineItem → Order
add :ingredient_id, references(:recipes, on_delete: :delete_all) # Ingredient → Recipe
# :nothing — resource is referenced but independent
add :user_id, references(:users, on_delete: :nothing) # Post → User
add :category_id, references(:categories, on_delete: :nothing) # Post → Category
# :nilify_all — remove reference but keep the record
add :team_id, references(:teams, on_delete: :nilify_all) # User → Team (user keeps account)
Every references() column needs an index. Without it, deleting a parent scans the entire child table.
# Bad — foreign key without index
create table(:comments) do
add :post_id, references(:posts, on_delete: :delete_all)
end
# Deleting a post requires full table scan of comments to find children
# Good — always add an index
create table(:comments) do
add :post_id, references(:posts, on_delete: :delete_all)
end
create index(:comments, [:post_id])
describe "create_post/1 with comments" do
test "creates post with nested comments" do
attrs = %{
title: "My Post",
comments: [
%{body: "Comment 1"},
%{body: "Comment 2"}
]
}
assert {:ok, post} = Blog.create_post(attrs)
assert post.title == "My Post"
post = Repo.preload(post, :comments)
assert length(post.comments) == 2
assert Enum.any?(post.comments, &(&1.body == "Comment 1"))
end
test "rejects invalid nested comments" do
attrs = %{
title: "My Post",
comments: [%{body: nil}]
}
assert {:error, changeset} = Blog.create_post(attrs)
assert errors_on(changeset)[:comments]
end
end
describe "update_recipe/2 with on_replace: :delete" do
test "removes omitted ingredients" do
recipe = recipe_fixture(ingredients: [%{name: "Salt"}, %{name: "Pepper"}])
recipe = Repo.preload(recipe, :ingredients)
# Only send Salt — Pepper should be deleted
attrs = %{ingredients: [%{id: hd(recipe.ingredients).id, name: "Salt"}]}
assert {:ok, updated} = Recipes.update_recipe(recipe, attrs)
updated = Repo.preload(updated, :ingredients, force: true)
assert length(updated.ingredients) == 1
assert hd(updated.ingredients).name == "Salt"
end
end
describe "place_order/2 with Ecto.Multi" do
test "creates order and line items atomically" do
user = user_fixture()
product = product_fixture(stock: 10)
items = [%{product_id: product.id, quantity: 2, price: 999}]
assert {:ok, %{order: order, line_items: {1, _}}} =
Orders.place_order(user, items)
assert order.user_id == user.id
end
test "rolls back on failure" do
user = user_fixture()
items = [%{product_id: -1, quantity: 2, price: 999}]
assert {:error, _step, _changeset, _changes} =
Orders.place_order(user, items)
end
end
See ecto-essentials skill for schema and migration fundamentals.
See ecto-changeset-patterns skill for changeset composition and validation.
See testing-essentials skill for comprehensive testing patterns.