Skip to content
Development
Skill

/ecto-essentials

Use when defining schemas, writing queries, or creating migrations — scoped context functions, Repo.transact, upserts, indexes, query composition, safe field casting. Not for multi-changeset design (ecto-changeset-patterns) or nested children (ecto-nested-associations).

BOOST
From plugin
elixir-phoenix-guide
16924 skills3 hooks
Install
$ npx -y skills add j-morgan6/elixir-phoenix-guide --skill ecto-essentials --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-essentials

Context preview

The summary Claude sees to decide when to auto-load this skill.

Use when defining schemas, writing queries, or creating migrations — scoped context functions, Repo.transact, upserts, indexes, query composition, safe field casting. Not for multi-changeset design (ecto-changeset-patterns) or nested children (ecto-nested-associations).

SKILL.md

ecto-essentials.SKILL.md
name: ecto-essentials
description: "Use when defining schemas, writing queries, or creating migrations — scoped context functions, Repo.transact, upserts, indexes, query composition, safe field casting. Not for multi-changeset design (ecto-changeset-patterns) or nested children (ecto-nested-associations)."

Ecto Essentials

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

RULES — Follow these with no exceptions

1. **Scope every context function** — take `%Scope{}` first and filter by owner (`Repo.get_by!(Post, id: id, user_id: scope.user.id)`), as the Phoenix 1.8 generators do; never load, update or delete by a client-supplied id alone 2. **Never `cast` foreign keys or privileged fields** (`:user_id`, `:folder_id`, `:role`, `:org_id`, `:confirmed_at`, `:deleted_at`) — set them with `put_change`/`Ecto.build_assoc` after a scope-checked lookup; use the 1.8 `changeset(struct, attrs, scope)` shape 3. **Always use changesets** for inserts and updates — never pass raw maps to Repo 4. **Use `Repo.transact/2` with `with`** for multi-step writes; `Repo.transaction/2` is soft-deprecated since Ecto 3.13. Reach for `Ecto.Multi` only when the steps are dynamic (see the `ecto-nested-associations` skill) 5. **Preload associations** before accessing them — avoid N+1 queries 6. **Add database constraints AND changeset constraint functions** — `unique_index` + `unique_constraint`, `references` + `foreign_key_constraint` 7. **Index every foreign key** and every column you filter or sort on 8. **Use `timestamps(type: :utc_datetime)`** in schemas and migrations — matches Phoenix 1.8's `timestamp_type: :utc_datetime` 9. **Escape `%`, `_` and `\` in user search terms** before building a `like`/`ilike` pattern 10. **Keep `change/0` migrations reversible** — `remove/3` with the type, `modify` with `from:`, `execute/2` with an up and a down 11. **Database access goes through contexts** — never call `Repo` from controllers or LiveViews

---

Schema and Changeset (1.8 scope pattern)

defmodule MyApp.Media.Image do
  use Ecto.Schema
  import Ecto.Changeset

  schema "images" do
    field :title, :string
    # Server-generated storage name (UUID + allowlisted extension)
    field :filename, :string
    # The client's file name, for display only
    field :original_name, :string
    # Detected from the file's bytes, not the client's claim
    field :content_type, :string
    field :byte_size, :integer

    belongs_to :folder, MyApp.Media.Folder
    belongs_to :user, MyApp.Accounts.User

    timestamps(type: :utc_datetime)
  end

  @doc false
  def changeset(image, attrs, scope) do
    image
    |> cast(attrs, [:title, :original_name])
    # filename/content_type/byte_size are set on the struct by the upload code
    # (see the phoenix-uploads skill); validate_required also checks struct data.
    |> validate_required([:title, :filename, :content_type, :byte_size])
    |> validate_length(:title, max: 255)
    |> put_change(:user_id, scope.user.id)
  end
end

This `Image` is the running example in the `phoenix-uploads`, `phoenix-liveview-essentials` (checklist) and `testing-essentials` (reference) skills too.

**Mass assignment:** anything in the `cast` list can be set from a form or JSON body (bad/good pair in the `ecto-changeset-patterns` skill). Set ownership from the scope and foreign keys from a record you loaded through the scope:

def move_image(%Scope{} = scope, %Image{} = image, folder_id) do
  true = image.user_id == scope.user.id
  folder = get_folder!(scope, folder_id)

  image
  |> Ecto.Changeset.change(folder_id: folder.id)
  |> Ecto.Changeset.foreign_key_constraint(:folder_id)
  |> Repo.update()
end

Context Pattern (scoped)

To generate this shape, or an org-scoped variant with `--scope org`, see the `phoenix-scopes-and-generators` skill.

Mirror `mix phx.gen.context` in 1.8: scope first, owner filter on every read, ownership assert on every write. (The generator also broadcasts `{:created | :updated | :deleted, image}` after each write; see the `phoenix-pubsub-patterns` skill.)

defmodule MyApp.Media do
  import Ecto.Query, warn: false

  alias MyApp.Accounts.Scope
  alias MyApp.Media.{Folder, Image}
  alias MyApp.Repo

  # filters: [folder_id: id, search: term, min_size: bytes] (see Query Composition)
  def list_images(%Scope{} = scope, filters \\ []) do
    Image
    |> where(user_id: ^scope.user.id)
    |> filter_images(filters)
    |> order_by(desc: :inserted_at, desc: :id)
    |> Repo.all()
  end

  def get_image!(%Scope{} = scope, id) do
    Repo.get_by!(Image, id: id, user_id: scope.user.id)
  end

  def get_folder!(%Scope{} = scope, id) do
    Repo.get_by!(Folder, id: id, user_id: scope.user.id)
  end

  # tmp_path is the upload's temp file; store_file/1 checks its magic bytes,
  # names it on the server and copies it. Storage fields never go through cast.
  def create_image(%Scope{} = scope, tmp_path, attrs) do
    with {:ok, stored} <- store_file(tmp_path) do
      Image
      |> struct!(stored)
      |> Image.changeset(attrs, scope)
      |> Repo.insert()
    end
  end

  def update_image(%Scope{} = scope, %Image{} = image, attrs) do
    true = image.user_id == scope.user.id

    image
    |> Image.changeset(attrs, scope)
    |> Repo.update()
  end

  # For uploaded files, the phoenix-uploads version also removes the stored file.
  def delete_image(%Scope{} = scope, %Image{} = image) do
    true = image.user_id == scope.user.id
    Repo.delete(image)
  end

  # Private helpers not shown: filter_images/2 (Query Composition below) and
  # store_file/1 (the phoenix-uploads skill).
end

<!-- epg:bad -->

# IDOR: any logged-in user can read or delete any image by guessing ids
def get_image!(id), do: Repo.get!(Image, id)

LiveViews and controllers pass `socket.assigns.current_scope` / `conn.assigns.current_scope`.

Query Composition

def search_images(%Scope{} = scope, term) do
  pattern =
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.