PhxMediaLibrary

Hex.pm Hex Docs License

Attach files to any Ecto schema, inspired by Spatie's Laravel Media Library.

You declare collections and conversions next to your schema, pipe an upload into a collection, and render it with a component. The library stores the file under a safe name, makes thumbnails, WebP, AVIF and responsive sizes in the background, and gives every image a tiny placeholder.

Quick look

defmodule MyApp.Post do
  use Ecto.Schema
  use PhxMediaLibrary.HasMedia

  schema "posts" do
    field :title, :string

    has_media()          # all media for this model
    has_media(:images)   # scoped to "images" collection
    has_media(:avatar)   # scoped to "avatar" collection

    timestamps()
  end

  media_collections do
    collection :images, max_files: 20, max_size: 10_000_000, webp: true, avif: true, responsive: true
    collection :documents, accepts: ~w(application/pdf text/plain), file_naming: :random
    collection :avatar, single_file: true, fallback_url: "/images/default.png"
  end

  media_conversions do
    convert :thumb, width: 150, height: 150, fit: :cover
    convert :preview, width: 800, quality: 85
    convert :banner, width: 1200, height: 400, fit: :crop, collections: [:images]
  end
end
{:ok, media} =
  post
  |> PhxMediaLibrary.add("/path/to/photo.jpg")
  |> PhxMediaLibrary.with_custom_properties(%{"alt" => "Red summer dress"})
  |> PhxMediaLibrary.to_collection(:images)

PhxMediaLibrary.get_media(post, :images)
PhxMediaLibrary.get_first_media_url(post, :images, :thumb)

PhxMediaLibrary.delete(media)
{:ok, count} = PhxMediaLibrary.clear_collection(post, :images)

LiveView uploads

defmodule MyAppWeb.PostLive.Edit do
  use MyAppWeb, :live_view
  use PhxMediaLibrary.LiveUpload

  def mount(%{"id" => id}, _session, socket) do
    post = Posts.get_post!(id)

    {:ok,
     socket
     |> assign(:post, post)
     |> allow_media_upload(:images, model: post, collection: :images)
     |> stream_existing_media(:media, post, :images)}
  end

  def handle_event("save_media", _params, socket) do
    case consume_media(socket, :images, socket.assigns.post, :images) do
      {:ok, saved} ->
        {:noreply, stream_media_items(socket, :media, saved)}

      {:error, {:partial, saved, failed}} ->
        {:noreply,
         socket
         |> stream_media_items(:media, saved)
         |> put_flash(:error, "#{length(failed)} file(s) could not be uploaded")}
    end
  end
end
<form phx-change="validate" phx-submit="save_media">
  <.media_upload upload={@uploads.images} id="post-images" />
  <button type="submit">Upload</button>
</form>

<.media_gallery media={@streams.media} id="gallery">
  <:item :let={{_id, media}}>
    <.media_img media={media} conversion={:thumb} class="rounded-lg" />
  </:item>
</.media_gallery>

Fast pages by default

The image components do the page-speed work for you:

<%!-- The hero: loads first, no lazy loading --%>
<.responsive_img media={@post.cover} priority class="w-full h-auto" />

<%!-- A product grid: lazy, and the browser picks the size for the slot --%>
<.responsive_img
  :for={product <- @products}
  media={product.photo}
  conversion={:preview}
  sizes="(max-width: 768px) 50vw, 25vw"
/>

Files named by the library

Uploaded filenames are never trusted. The library picks the stored name: posts_42_images_<uuid>.jpg by default, a readable summer-dress.jpg with file_naming: :slug (Cyrillic is transliterated), or a name that reveals nothing with :random. The uploaded name is kept, and the library uses it for alt text and as the name of downloads, Лятна рокля.pdf included.

A doctor for your media

$ mix phx_media_library.doctor               # report only
$ mix phx_media_library.doctor --regenerate  # make missing thumbnails, WebP, AVIF, placeholders
$ mix phx_media_library.doctor --rename      # move older files to the naming scheme
$ mix phx_media_library.doctor --id <uuid>   # one media item
$ bin/media_doctor --regenerate --yes        # in a release (mix phx_media_library.gen.release)

It finds media with no files left, missing derivatives, lost metadata, checksum mismatches (--deep) and records whose parent is gone. Repairs show their plan and ask first. It can also run daily by itself, from config:

config :phx_media_library, :doctor_schedule, enabled: true, repair: [:missing_placeholder]

Uploads straight to S3 or R2

presigned_upload/3 signs a browser upload to S3, Cloudflare R2 or any S3-compatible service, as a form POST or a PUT, whichever the provider supports. complete_external_upload/4 then reads the stored file and checks its size and type against the collection before recording it. The storage guide has a LiveView uploader.

Features

Category What you get
Schema integration Polymorphic has_media() macro, a DSL for collections and conversions
Collections MIME validation, file count and size limits, pixel and dimension limits (min_width:, max_height:, ...), single-file mode, fallback URLs
Image conversions Resizes and crops, WebP by default or every format of formats: [:avif, :webp] (format: :original keeps the upload's format), responsive srcset; same-shape conversions share the original's variants. Optional: works without libvips
PDF thumbnails A PDF's first page gives its conversions and placeholder, with pdftoppm or a libvips that reads PDFs; the PDF stays the download
Placeholders A 32px blurred WebP (or the average color) for every image, made in the background, shown by every image component
AVIF Opt-in avif: true: an AVIF of each image and its sizes, offered first through <picture> with WebP or the original as the fallback
WebP & HEIC Conversions are WebP; webp: true also transcodes raster and HEIC originals to WebP and serves them
File naming :semantic (default), :slug or :random stored names; the uploaded name kept for alt text and downloads
Metadata Dimensions, EXIF, format and type stored in a metadata JSON field
Signed and download URLs Expiring URLs on disk and S3; downloads keep the uploaded name, with a UTF-8 filename*
Bulk import import_media/2: thousands of files across records and collections, rows in batches, derivatives in the background
Any source Paths, uploads, {:binary, ...}, {:base64, ...} (data URIs too) and {:stream, ...}, all checked like uploads
Remote URLs add_from_url/3 with SSRF protection, streaming to disk, size and time limits
Databases PostgreSQL and SQLite (ecto_sqlite3), the whole suite run on both
Storage Local disk, S3 and S3-compatible services (Cloudflare R2, Google Cloud Storage, MinIO), in-memory for tests, or your own adapter
Direct S3 uploads presigned_upload/3 + complete_external_upload/4, POST or PUT per provider, limits checked on completion
Streaming Files go to storage in 64 KB chunks, never whole in memory; downloads stream with send_file
Async processing Task (default), Oban or inline; conversions and placeholders run in the background, and Media.processing_status/1 says whether they are queued, running, done or failed
LiveView <.media_upload> and <.media_gallery> components, LiveUpload helpers with partial-failure results
View helpers <.media_img>, <.responsive_img>, <.picture> and <.media_figure> (image with caption)
Security Content-based MIME detection, SHA-256 checksums, safe stored names, SVGs served sandboxed (Plug.StaticHeaders)
Doctor mix phx_media_library.doctor checks files, metadata, names and checksums, and repairs what it can; in releases too, and daily from config
Soft deletes Opt-in deleted_at with restore/1, purge_trashed/2 and a purge task
Batch ops clear_collection/2, clear_media/1, reorder/3, set_position/2
Copy and move copy_media/4 and move_media/4 to another record or collection, under the target collection's rules
Telemetry :start/:stop/:exception spans for add, delete, conversion, storage, batch, download
Errors Tagged tuples and structured exceptions (Error, StorageError, ValidationError)
Queries media_query/2 returns a composable Ecto.Query

Installation

def deps do
  [
    {:phx_media_library, "~> 0.10"},

    # Optional: image processing (requires libvips)
    {:image, "~> 0.54"},

    # Optional: S3 storage
    {:ex_aws, "~> 2.5"},
    {:ex_aws_s3, "~> 2.5"},
    {:sweet_xml, "~> 0.7"},

    # Optional: async processing with Oban
    {:oban, "~> 2.18"}
  ]
end
# config/config.exs
config :phx_media_library,
  repo: MyApp.Repo,
  default_disk: :local,

  # The column type of your models' primary keys.
  # :binary_id  UUID (default, keeps existing installs working)
  # :integer    bigint (integer primary keys)
  # :string     varchar (any primary key type; recommended for new apps)
  mediable_id_type: :string,

  disks: [
    local: [
      adapter: PhxMediaLibrary.Storage.Disk,
      root: "priv/static/uploads",
      base_url: "/uploads"
    ]
  ]
# For integer primary keys, pass --id-type to generate the right column:
mix phx_media_library.install --id-type integer
mix ecto.migrate

mediable_id_type sets the column type of the polymorphic foreign key. The default stays :binary_id (UUID) so existing installs keep working; :string works with any primary key type. Set it explicitly and a future change of default won't affect you.

The :image dependency is optional. Without it the library stores files; conversions, WebP, AVIF and placeholders need it.

Guides

Guide Covers
Getting Started Installation, configuration, schema setup, adding and retrieving media
Collections & Conversions Validation, image processing, WebP, AVIF, placeholders, responsive images
LiveView Integration Upload and gallery components, LiveUpload helpers, events, view helpers
Storage Local disk, S3 and R2, direct uploads, signed URLs, caching, custom adapters
Error Handling Tagged tuples, custom exceptions, MIME detection
Telemetry Events reference, attaching handlers, metrics examples
Advanced Usage Reordering, mix tasks, testing strategies
Multi-tenant Per-tenant storage paths and scoping
Upgrading to 0.10 The migration, the rename, the removals, and what changed
Upgrading to 0.9 Every behaviour change, and how to keep the old one

Full API documentation is on HexDocs.

Contributing

Contributions are welcome. Please open a pull request.

The tests need Postgres on localhost (user and password postgres), for example in a container of its own:

podman run -d --name phx-media-library-db -e POSTGRES_PASSWORD=postgres -p 5432:5432 postgres:18-alpine

DB=sqlite mix test runs the same suite on SQLite, in a file under tmp/.

The S3 tests need podman or Docker: they run against a Ministack container that the first mix test starts and later runs reuse. The PDF thumbnail tests need poppler's pdftoppm, and one video test needs FFmpeg. Each group is skipped when its tool is missing. Run mix precommit, which runs the suite on both databases, before opening a pull request.

  1. Fork it
  2. Create your feature branch (git checkout -b feature/my-feature)
  3. Commit your changes (git commit -am 'Add my feature')
  4. Push to the branch (git push origin feature/my-feature)
  5. Create a pull request

License

MIT. See the LICENSE file.

Acknowledgments

Inspired by Spatie's Laravel Media Library.