code-quality
Use when refactoring or reviewing Elixir code quality — compiler warnings and Credo first, then the plugin's on-demand…
Use when one form or operation manages parent and child records together — cast_assoc/cast_embed, inputs_for with sort/drop params, on_replace, Ecto.Multi across tables, FK cascade design.
$ npx -y skills add j-morgan6/elixir-phoenix-guide --skill ecto-nested-associations --agent claude-codeHow it fires
How this skill gets triggered: by you, by Claude, or both.
/ecto-nested-associationsContext preview
The summary Claude sees to decide when to auto-load this skill.
Use when one form or operation manages parent and child records together — cast_assoc/cast_embed, inputs_for with sort/drop params, on_replace, Ecto.Multi across tables, FK cascade design.
name: ecto-nested-associations description: "Use when one form or operation manages parent and child records together — cast_assoc/cast_embed, inputs_for with sort/drop params, on_replace, Ecto.Multi across tables, FK cascade design."
> Verified against Elixir 1.20, Phoenix 1.8, LiveView 1.2, Ecto 3.14 — October 2026
1. **Use `cast_assoc/3` / `cast_embed/3` for children edited with their parent** — never insert children in a separate step; Ecto sets the foreign key 2. **Child changesets never cast or require the parent's foreign key** — `cast_assoc` sets it after the parent is inserted; requiring it gives "can't be blank" 3. **`on_replace` is an option on the association, not on `cast_assoc`** — `has_many :items, Item, on_replace: :delete`. The default is `:raise`: omitting an existing child from the params raises 4. **Load the parent through the scope and preload before casting** — `cast_assoc/3` raises on `%Ecto.Association.NotLoaded{}` 5. **Edit child lists from `<.inputs_for>` with `:sort_param` / `:drop_param`** — plus `on_replace: :delete`, and a `with:` function of arity 3 when order must persist 6. **`Repo.transact/2` + `with` for a fixed set of steps; `Ecto.Multi` when the steps depend on input** (one step per cart item) 7. **Never read-check-write a counter** — decrement with `update_all` guarded by `where: p.stock >= ^qty` and check the affected count is 1 8. **Choose `on_delete` deliberately** — `:delete_all` for owned children, `:nilify_all` (nullable column) to keep them, `:restrict` + `no_assoc_constraint/3` to block; the default `:nothing` also blocks the delete 9. **Index every foreign key column** — deleting a parent otherwise scans the child table
---
defmodule MyApp.Recipes.Recipe do
use Ecto.Schema
import Ecto.Changeset
alias MyApp.Recipes.Ingredient
schema "recipes" do
field :name, :string
field :user_id, :id
has_many :ingredients, Ingredient,
on_replace: :delete,
preload_order: [asc: :position]
timestamps(type: :utc_datetime)
end
def changeset(recipe, attrs, scope) do
recipe
|> cast(attrs, [:name])
|> validate_required([:name])
|> cast_assoc(:ingredients,
with: &Ingredient.changeset/3,
sort_param: :ingredients_sort,
drop_param: :ingredients_drop
)
|> put_change(:user_id, scope.user.id)
end
end
defmodule MyApp.Recipes.Ingredient do
use Ecto.Schema
import Ecto.Changeset
schema "ingredients" do
field :name, :string
field :quantity, :string
field :position, :integer
belongs_to :recipe, MyApp.Recipes.Recipe
timestamps(type: :utc_datetime)
end
# Arity 3: cast_assoc passes the item's final position (nil when it is being replaced).
# This third argument is the position, not the 1.8 scope; children get ownership via the parent.
def changeset(ingredient, attrs, position) do
ingredient
|> cast(attrs, [:name, :quantity])
|> validate_required([:name])
|> change(position: position)
end
end<!-- epg:bad -->
# Child changeset that casts and requires the parent FK: "recipe_id can't be blank" on create, # and a client could re-parent an ingredient onto someone else's recipe def changeset(ingredient, attrs) do ingredient |> cast(attrs, [:name, :quantity, :recipe_id]) |> validate_required([:name, :recipe_id]) end
<!-- epg:bad -->
# on_replace is not a cast_assoc option — the association keeps its :raise default |> cast_assoc(:ingredients, on_replace: :delete)
def get_recipe!(%Scope{} = scope, id) do
Recipe
|> Repo.get_by!(id: id, user_id: scope.user.id)
|> Repo.preload(:ingredients)
end
# A new %Recipe{} needs no preload — built structs cast children against []
def create_recipe(%Scope{} = scope, attrs) do
%Recipe{}
|> Recipe.changeset(attrs, scope)
|> Repo.insert()
end
def update_recipe(%Scope{} = scope, %Recipe{} = recipe, attrs) do
true = recipe.user_id == scope.user.id
recipe
|> Repo.preload(:ingredients)
|> Recipe.changeset(attrs, scope)
|> Repo.update()
endWith `on_replace: :delete`, children are matched by `id`: given ingredients A, B, C and params `%{"ingredients" => [%{"id" => a.id, "name" => "A2"}, %{"name" => "D"}]}`, A is updated, B and C are deleted, D is inserted.
<!-- epg:bad -->
recipe = Repo.get_by!(Recipe, id: id, user_id: scope.user.id)
# ingredients is %Ecto.Association.NotLoaded{} — cast_assoc inside Recipe.changeset/3 raises
# "attempting to cast or change association `ingredients` ... that was not loaded",
# before Repo.update/1 is ever called
recipe |> Recipe.changeset(attrs, scope) |> Repo.update()The supported way to add, remove and reorder children from a LiveView form. Ecto reads `ingredients_sort` / `ingredients_drop` from the params (names must match the `cast_assoc` options):
<.form for={@form} id="recipe-form" phx-change="validate" phx-submit="save">
<.input field={@form[:name]} type="text" label="Name" />
<.inputs_for :let={f_ing} field={@form[:ingredients]}>
<input type="hidden" name="recipe[ingredients_sort][]" value={f_ing.index} />
<.input field={f_ing[:name]} type="text" placeholder="Ingredient" />
<.input field={f_ing[:quantity]} type="text" placeholder="Quantity" />
<button
type="button"
name="recipe[ingredients_drop][]"
value={f_ing.index}
phx-click={JS.dispatch("change")}
>
Remove
</button>
</.inputs_for>
<input type="hidden" name="recipe[ingredients_drop][]" />
<button
type="button"
name="recipe[ingredients_sort][]"
value="new"
phx-click={JS.dispatch("change")}
>
Add ingredient
</button>
<.button phx-disable-with="Saving...">Save</.button>
</.form>· 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 writing Ecto changeset validations beyond cast/validate_required — custom and cross-field validations (e.g. end date…
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 a query is slow, N+1 or hard to compose — preload vs join-preload, dynamic/2, subquery/exists, Repo.aggregate,…