PhxMediaLibrary
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"
/>
- Placeholders. Every image gets a 32px blurred WebP of about 260
bytes, set as its background until it loads. No JavaScript, no layout
jump.
placeholder: :coloruses the average color instead. - No layout shift.
widthandheightcome from the stored file, so the browser reserves the space. - The right size. With
responsive: true,srcsetlists every variant, and lazy images default tosizes="auto, 100vw", so supporting browsers measure the slot instead of assuming the full screen. - Small formats. Conversions are WebP by default, or every format in
formats: [:avif, :webp], with the last one in plain URLs.webp: truealso serves the original as WebP;avif: trueadds an AVIF of the original and of every size, which<.picture>and<.responsive_img>offer first, with WebP or the original as the fallback. A plain URL is never AVIF, since AI tools and most link previews can't read it. - Priority.
prioritymarks the page's main imageloading="eager"andfetchpriority="high"; other images decode asynchronously. - Caching.
cdn_url/2adds a content fingerprint (?vsn=…) thatPlug.Staticanswers with a one-year cache.
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.
- Fork it
- Create your feature branch (
git checkout -b feature/my-feature) - Commit your changes (
git commit -am 'Add my feature') - Push to the branch (
git push origin feature/my-feature) - Create a pull request
License
MIT. See the LICENSE file.
Acknowledgments
Inspired by Spatie's Laravel Media Library.