Skip to content
Development
Skill

/ecto-nested-associations

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.

BOOST
From plugin
elixir-phoenix-guide
16924 skills3 hooks
Install
$ npx -y skills add j-morgan6/elixir-phoenix-guide --skill ecto-nested-associations --agent claude-code

How it fires

How this skill gets triggered: by you, by Claude, or both.

  • Fires itselfAuto-invocation. Claude auto-loads it when your prompt matches the work.Auto-invocation is when the right skill fires by itself at the right moment, driven by a FLOW.md router and a hook, instead of you invoking it by name. It is the difference between a skill being installed and a skill actually getting used.Read the full definition →
  • You can call itInvoke it directly when you want it.
  • Slash command/ecto-nested-associations

Context 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.

SKILL.md

ecto-nested-associations.SKILL.md
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."

Ecto Nested Associations

> Verified against Elixir 1.20, Phoenix 1.8, LiveView 1.2, Ecto 3.14 — October 2026

RULES — Follow these with no exceptions

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

---

Schemas for a Nested Form

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)

Context: Create and Update

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()
end

With `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()

Editing Child Lists with `<.inputs_for>`

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>
  • `value="new"` in the sort param appends an empty ch
Read more
Ships withelixir-phoenix-guide

· Changelog A Claude Code plugin (with Codex support for the skills) that keeps Claude's Elixir and Phoenix code idiomatic and current.

Get the whole plugin

Other skills on elixir-phoenix-guide.