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.
webp: truealso serves the original as WebP;avif: trueadds an AVIF of the original and its sizes, 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
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.
- 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.