Skip to content
Development
Skill

/phoenix-uploads

Use when implementing file uploads or serving uploaded files — allow_upload, consume_uploaded_entries, content-type allowlists, validators, local vs S3/external storage, authorized downloads.

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

Context preview

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

Use when implementing file uploads or serving uploaded files — allow_upload, consume_uploaded_entries, content-type allowlists, validators, local vs S3/external storage, authorized downloads.

SKILL.md

phoenix-uploads.SKILL.md
name: phoenix-uploads
description: "Use when implementing file uploads or serving uploaded files — allow_upload, consume_uploaded_entries, content-type allowlists, validators, local vs S3/external storage, authorized downloads."

Phoenix File Uploads

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

RULES — Follow these with no exceptions

1. **Never take the stored extension or the served content type from the client.** LiveView accepts an entry when its client MIME type **or** its file extension matches `:accept`, so `x.html` sent as `image/png` passes. Sniff the magic bytes of the uploaded file and pick the extension from an allowlist keyed on the detected type. 2. **Name files on the server** — `Ecto.UUID.generate() <> allowlisted_ext`. Never put `entry.client_name` (or any request param) in a path. 3. **Store under a configured directory, not a cwd-relative `"priv/static/uploads"`.** `priv/static` is replaced on every release deploy and runtime files are never in the digest manifest. Read the directory from app config at runtime and serve it with its own `Plug.Static` (or use object storage via `:external`). 4. **Private files go through a controller that loads the record through the scope** — `Media.get_document!(scope, id)`. Never accept a filename or path from the request. Send an allowlisted `content_type`, `disposition: :attachment` and `x-content-type-options: nosniff`. 5. **The `consume_uploaded_entries/3` callback must return `{:ok, value}` or `{:postpone, value}`.** Wrap the context result (`{:ok, Media.create_image(...)}`) and handle failures after consuming. 6. **Render `upload_errors/1` and `upload_errors/2`, and make `error_to_string/1` total** — atoms, `{:external_metadata_failure, _}`, `{:writer_failure, _}`, your `validator:` reasons, and a catch-all. 7. **Form markup is Phoenix 1.8:** assign `:form` in `mount`, give `<.form>` an `id`, put `<.button>` directly inside it (`<.form>` has no `actions` slot), and use a plain `<label for={@uploads.images.ref}>` (1.8 `core_components` has no `label/1` component). 8. **Default to manual uploads.** With `auto_upload: true`, `consume_uploaded_entries/3` raises while any entry is still in progress; consume from a `progress:` callback once `entry.done?`. 9. **External uploads need both halves:** the `:external` function returns meta with an `:uploader` name, and that name must be registered in `uploaders:` on the JS `LiveSocket`.

---

Complete Upload Pattern (local storage)

Context: sniff, name, store

The `MyApp.Media.Image` schema is the one in the `ecto-essentials` skill: `title`, `filename` (server-generated), `original_name` (display only), `content_type`, `byte_size`, `folder_id`, `user_id`. Its `changeset/3` casts only `[:title, :original_name]`.

defmodule MyApp.Media do
  alias MyApp.Accounts.Scope
  alias MyApp.Media.Image
  alias MyApp.Repo

  # Detected content type => extension we store under. The client's name and
  # type never decide either.
  @exts %{"image/jpeg" => ".jpg", "image/png" => ".png", "image/webp" => ".webp"}

  def allowed_type?(content_type), do: Map.has_key?(@exts, content_type)

  def create_image(%Scope{} = scope, tmp_path, attrs) do
    with {:ok, stored} <- store_file(tmp_path) do
      # Storage fields are set on the struct, never cast from attrs.
      changeset = Image |> struct!(stored) |> Image.changeset(attrs, scope)

      case Repo.insert(changeset) do
        {:ok, image} ->
          {:ok, image}

        {:error, changeset} ->
          # Don't leave an orphaned file behind a failed insert.
          remove_file(stored.filename)
          {:error, changeset}
      end
    end
  end

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

    with {:ok, image} <- Repo.delete(image) do
      remove_file(image.filename)
      {:ok, image}
    end
  end

  # list_images/2, get_image!/2, update_image/3: see the ecto-essentials skill.

  def image_url(%Image{filename: filename}), do: "/uploads/" <> filename

  # Resolved at runtime, so prod can point at a mounted volume (config/runtime.exs).
  def uploads_dir, do: Application.fetch_env!(:my_app, :uploads_dir)

  defp store_file(tmp_path) do
    with {:ok, content_type} <- sniff_image(tmp_path) do
      filename = Ecto.UUID.generate() <> Map.fetch!(@exts, content_type)
      File.mkdir_p!(uploads_dir())
      File.cp!(tmp_path, Path.join(uploads_dir(), filename))

      {:ok, %{filename: filename, content_type: content_type, byte_size: File.stat!(tmp_path).size}}
    end
  end

  defp remove_file(filename), do: File.rm(Path.join(uploads_dir(), filename))

  # Magic bytes from the file itself.
  defp sniff_image(path) do
    case File.open(path, [:read, :binary], &IO.binread(&1, 12)) do
      {:ok, <<0x89, "PNG", 0x0D, 0x0A, 0x1A, 0x0A, _::binary>>} -> {:ok, "image/png"}
      {:ok, <<0xFF, 0xD8, 0xFF, _::binary>>} -> {:ok, "image/jpeg"}
      {:ok, <<"RIFF", _::binary-size(4), "WEBP", _::binary>>} -> {:ok, "image/webp"}
      _ -> {:error, :invalid_content}
    end
  end
end

For more formats, the `file_type` package sniffs many types: `FileType.from_path(path)` returns `{:ok, {"png", "image/png"}}` or `{:error, reason}`. Still map the detected MIME type through your own allowlist. Broadcasts (`{:created, image}` etc.) follow the `phoenix-pubsub-patterns` skill.

LiveView

defmodule MyAppWeb.ImageLive.Index do
  use MyAppWeb, :live_view

  alias MyApp.Media

  @impl true
  def mount(_params, _session, socket) do
    {:ok,
     socket
     |> assign(:form, to_form(%{"title" => ""}, as: :image))
     |> stream(:images, Media.list_images(socket.assigns.current_scope))
     |> allow_upload(:images,
       accept: ~w(.jpg .jpeg .png .webp),
       max_entries: 5,
       max_file_size: 10_000_000,
       # Runs on client metadata, before any bytes arrive: early feedback only.
       # The magic-byte check in the cont
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.