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

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.

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 limits, single-file mode, fallback URLs
Image conversions Resizes and crops, WebP by default (format: :original keeps the upload's format), responsive srcset; same-shape conversions share the original's variants. Optional: works without libvips
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*
Remote URLs add_from_url/3 with SSRF protection, streaming to disk, size and time limits
Storage Local disk, S3 and S3-compatible services (Cloudflare R2, 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
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
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, move_to/2
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.9"},
# 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.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), and podman or Docker for the S3 tests. Those run against a Ministack container that the first mix test starts and later runs reuse; without podman or Docker they are skipped. Run mix precommit 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.