PhxMediaLibrary
A robust, Ecto-backed media management library for Elixir and Phoenix, inspired by Spatie's Laravel Media Library.
Features
- Associate files with Ecto schemas — Polymorphic media associations via
has_media()macro - Declarative DSL — Define collections and conversions with a clean macro syntax
- Collections — Organize media into named collections with MIME validation, file limits, and single-file mode
- Image conversions — Generate thumbnails, previews, and custom sizes (optional — works without libvips)
- Responsive images — Automatic srcset generation for optimal loading
- Multiple storage backends — Local filesystem, S3, in-memory (for tests), or custom adapters
- Async processing — Background conversion processing with Task (default) or Oban
- LiveView components — Drop-in
<.media_upload>and<.media_gallery>components that eliminate upload boilerplate - LiveUpload helpers —
use PhxMediaLibrary.LiveUploadfor collection-aware uploads with one line - Checksum integrity — SHA-256 computed on upload, verifiable at any time
- Composable queries —
media_query/2returns anEcto.Queryfor further composition - Phoenix view helpers —
<.media_img>,<.responsive_img>, and<.picture>components
Installation
Add phx_media_library to your dependencies in mix.exs:
def deps do
[
{:phx_media_library, "~> 0.2.0"},
# 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"}
]
endNote: The
:imagedependency (libvips) is optional. PhxMediaLibrary works for file storage (PDFs, CSVs, documents) without it. Image conversions and responsive images require:imageto be installed. If it's missing, you'll get clear error messages guiding you to install it.
Configuration
Add the required configuration to your config/config.exs:
config :phx_media_library,
repo: MyApp.Repo,
default_disk: :local,
disks: [
local: [
adapter: PhxMediaLibrary.Storage.Disk,
root: "priv/static/uploads",
base_url: "/uploads"
]
]Storage Options
Local Disk (Default)
config :phx_media_library,
disks: [
local: [
adapter: PhxMediaLibrary.Storage.Disk,
root: "priv/static/uploads",
base_url: "/uploads"
]
]Amazon S3
config :phx_media_library,
default_disk: :s3,
disks: [
s3: [
adapter: PhxMediaLibrary.Storage.S3,
bucket: "my-bucket",
region: "us-east-1"
]
]
# Configure ExAws credentials
config :ex_aws,
access_key_id: {:system, "AWS_ACCESS_KEY_ID"},
secret_access_key: {:system, "AWS_SECRET_ACCESS_KEY"}Responsive Images (Optional)
config :phx_media_library,
responsive_images: [
enabled: true,
widths: [320, 640, 960, 1280, 1920],
tiny_placeholder: true
]Async Processing with Oban (Optional)
config :phx_media_library,
async_processor: PhxMediaLibrary.AsyncProcessor.ObanQuick Start
1. Run the installer
mix phx_media_library.install
mix ecto.migrate
This generates the media table migration with all required fields.
2. Add to your Ecto schema
PhxMediaLibrary supports two styles for defining collections and conversions. You can use either — or mix them.
Declarative DSL (recommended)
defmodule MyApp.Post do
use Ecto.Schema
use PhxMediaLibrary.HasMedia
schema "posts" do
field :title, :string
has_media() # injects has_many :media (all media for this model)
has_media(:images) # injects has_many :images (scoped to "images" collection)
has_media(:avatar) # injects has_many :avatar (scoped to "avatar" collection)
timestamps()
end
media_collections do
collection :images, max_files: 20
collection :documents, accepts: ~w(application/pdf text/plain)
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
endFunction-based approach
defmodule MyApp.Post do
use Ecto.Schema
use PhxMediaLibrary.HasMedia
schema "posts" do
field :title, :string
has_media()
timestamps()
end
def media_collections do
[
collection(:images),
collection(:documents, accepts: ~w(application/pdf)),
collection(:avatar, single_file: true)
]
end
def media_conversions do
[
conversion(:thumb, width: 150, height: 150, fit: :cover),
conversion(:preview, width: 800, quality: 85)
]
end
end
The has_media() macro injects a polymorphic has_many :media association so you can use standard Ecto preloading:
post = Repo.get!(Post, id) |> Repo.preload([:media, :images, :avatar])
Collection-scoped variants like has_media(:images) add a scoped has_many filtered by both model type and collection name.
3. Add media to your models
# From a file path
{:ok, media} =
post
|> PhxMediaLibrary.add("/path/to/image.jpg")
|> PhxMediaLibrary.to_collection(:images)
# With custom filename and metadata
{:ok, media} =
post
|> PhxMediaLibrary.add(upload)
|> PhxMediaLibrary.using_filename("custom-name.jpg")
|> PhxMediaLibrary.with_custom_properties(%{"alt" => "My image"})
|> PhxMediaLibrary.to_collection(:images)
# From a URL
{:ok, media} =
post
|> PhxMediaLibrary.add_from_url("https://example.com/image.jpg")
|> PhxMediaLibrary.to_collection(:images)
# Bang version raises on error
media = PhxMediaLibrary.to_collection!(adder, :images)4. Retrieve media
# Get all media in a collection
PhxMediaLibrary.get_media(post, :images)
# Get the first media item
PhxMediaLibrary.get_first_media(post, :images)
# Get URLs
PhxMediaLibrary.get_first_media_url(post, :images)
PhxMediaLibrary.get_first_media_url(post, :images, :thumb)
PhxMediaLibrary.get_first_media_url(post, :avatar, fallback: "/default.jpg")
# Get URL for a specific media item
PhxMediaLibrary.url(media)
PhxMediaLibrary.url(media, :thumb)
# Composable Ecto queries
PhxMediaLibrary.media_query(post, :images)
|> where([m], m.mime_type == "image/png")
|> limit(5)
|> Repo.all()LiveView Components
PhxMediaLibrary ships with drop-in LiveView components that eliminate 150+ lines of upload boilerplate.
Setup
Add to your my_app_web.ex:
defp html_helpers do
quote do
# ... existing imports
import PhxMediaLibrary.Components
import PhxMediaLibrary.ViewHelpers
end
endUpload + Gallery in a LiveView
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("validate", _params, socket) do
{:noreply, socket}
end
def handle_event("save_media", _params, socket) do
case consume_media(socket, :images, socket.assigns.post, :images, notify: self()) do
{:ok, media_items} ->
{:noreply,
socket
|> stream_media_items(:media, media_items)
|> put_flash(:info, "Uploaded #{length(media_items)} file(s)")}
{:error, reason} ->
{:noreply, put_flash(socket, :error, "Upload failed: #{inspect(reason)}")}
end
end
def handle_event("delete_media", %{"id" => id}, socket) do
case delete_media_by_id(id, notify: self()) do
:ok -> {:noreply, stream_delete_by_dom_id(socket, :media, "media-#{id}")}
{:error, reason} -> {:noreply, put_flash(socket, :error, inspect(reason))}
end
end
def handle_event("cancel_upload", %{"ref" => ref}, socket) do
{:noreply, cancel_upload(socket, :images, ref)}
end
# React to media lifecycle events
def handle_info({:media_added, media_items}, socket) do
{:noreply, assign(socket, :media_count, length(media_items))}
end
def handle_info({:media_removed, _media}, socket) do
{:noreply, socket}
end
endTemplate
<form phx-change="validate" phx-submit="save_media">
<.media_upload
upload={@uploads.images}
id="post-images-upload"
label="Upload Images"
sublabel="JPG, PNG, WebP up to 10MB"
/>
<button type="submit">Upload</button>
</form>
<.media_gallery
media={@streams.media}
id="post-gallery"
>
<:item :let={{id, media}}>
<.media_img media={media} conversion={:thumb} class="rounded-lg" />
</:item>
<:empty>
<p>No images yet. Upload some above!</p>
</:empty>
</.media_gallery><.media_upload> Features
-
Drag-and-drop with visual feedback (
.MediaDropZoneJS hook) -
Live image previews via
<.live_img_preview> - Upload progress bars per entry
- Per-entry error display and cancel buttons
- Full-size and compact layouts
- Dark mode support
- Fully customizable via attrs, slots, and CSS classes
<.media_gallery> Features
- Stream-powered grid (2–6 configurable columns)
- Image thumbnails with delete-on-hover
- Document type icons (PDF, spreadsheet, archive, etc.)
:itemand:emptyslots for custom rendering
<.media_upload_button>
A compact inline variant for embedding upload triggers within forms or tight layouts.
PhxMediaLibrary.LiveUpload Helpers
use PhxMediaLibrary.LiveUpload imports these functions into your LiveView:
| Function | Purpose |
|---|---|
allow_media_upload/3 |
Wraps allow_upload/3 with collection-aware defaults (accept types, max entries, max file size) |
consume_media/5 |
Consumes uploads and persists via PhxMediaLibrary.add/2 |> to_collection/2 |
stream_existing_media/4 | Loads existing media into a LiveView stream |
stream_media_items/3 | Inserts newly created media into a stream |
delete_media_by_id/2 | Deletes a media record and its files |
media_upload_errors/1 | Human-readable error strings for an upload |
media_entry_errors/2 | Human-readable error strings for an entry |
has_upload_entries?/1 | Whether the upload has any entries |
image_entry?/1 | Whether an entry is an image (for conditional previews) |
translate_upload_error/1 | Extensible error atom → string translation |
Event Notifications
Both consume_media/5 and delete_media_by_id/2 accept a :notify option. When set to a pid (e.g. self()), lifecycle messages are sent to that process:
{:media_added, [Media.t()]}— after successful upload{:media_error, reason}— when upload fails{:media_removed, Media.t()}— after successful deletion
Handle them in your LiveView via handle_info/2.
Collections
Collections organize media and apply validation rules.
media_collections do
# Basic collection
collection :images
# MIME type validation
collection :documents, accepts: ~w(application/pdf application/msword)
# Single file only (replaces existing on new upload)
collection :avatar, single_file: true
# Limit number of files (oldest excess is removed)
collection :gallery, max_files: 10
# Custom storage disk
collection :backups, disk: :s3
# Fallback URL when collection is empty
collection :profile_photo, single_file: true, fallback_url: "/images/default-avatar.png"
endConversions
Conversions automatically generate derived images when media is added. Requires the :image dependency.
media_conversions do
# Simple resize
convert :thumb, width: 150, height: 150
# Resize with fit mode
convert :square, width: 300, height: 300, fit: :cover
# Width only (maintains aspect ratio)
convert :preview, width: 800
# With quality setting
convert :optimized, width: 1200, quality: 80
# Convert format
convert :webp_thumb, width: 150, format: :webp
# Only for specific collections
convert :banner, width: 1200, collections: [:images, :gallery]
endFit Options
| Mode | Behaviour |
|---|---|
:contain | Fit within dimensions, maintaining aspect ratio |
:cover | Cover dimensions, cropping if necessary |
:fill | Stretch to fill dimensions exactly |
:crop | Crop to exact dimensions from center |
Checksum & Integrity Verification
SHA-256 checksums are computed automatically during upload and stored alongside each media record.
# Verify a file hasn't been tampered with or corrupted
case PhxMediaLibrary.verify_integrity(media) do
:ok -> IO.puts("File is intact")
{:error, :checksum_mismatch} -> IO.puts("File has been corrupted!")
{:error, :no_checksum} -> IO.puts("No checksum stored for this media")
endResponsive Images
Generate multiple sizes for optimal loading across devices.
# Enable when adding media
post
|> PhxMediaLibrary.add(upload)
|> PhxMediaLibrary.with_responsive_images()
|> PhxMediaLibrary.to_collection(:images)
# Get srcset attribute
PhxMediaLibrary.srcset(media)
# => "uploads/posts/1/responsive/image-320.jpg 320w, ..."View Helpers
For standard (non-LiveView) templates, PhxMediaLibrary provides rendering components.
Simple Image
<.media_img media={@media} class="rounded-lg" />
<.media_img media={@media} conversion={:thumb} alt="Product image" />Responsive Image
<.responsive_img
media={@media}
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 800px"
class="w-full h-auto"
alt="Hero image"
/>Picture Element (Art Direction)
<.picture
media={@media}
sources={[
%{media: "(max-width: 768px)", conversion: :mobile},
%{media: "(min-width: 769px)", conversion: :desktop}
]}
alt="Responsive artwork"
/>Mix Tasks
Install
mix phx_media_library.installRegenerate Conversions
mix phx_media_library.regenerate --conversion thumb
mix phx_media_library.regenerate --collection images
mix phx_media_library.regenerate --dry-runRegenerate Responsive Images
mix phx_media_library.regenerate_responsive
mix phx_media_library.regenerate_responsive --collection imagesClean Orphaned Files
# Dry run — see what would be deleted
mix phx_media_library.clean
# Actually delete
mix phx_media_library.clean --forceGenerate Custom Migration
mix phx_media_library.gen.migration add_blurhash_fieldDeleting Media
# Delete a single media item (removes files from storage too)
PhxMediaLibrary.delete(media)
# Clear all media in a collection
PhxMediaLibrary.clear_collection(post, :images)
# Clear all media for a model
PhxMediaLibrary.clear_media(post)Custom Storage Adapters
Implement the PhxMediaLibrary.Storage behaviour:
defmodule MyApp.Storage.CustomAdapter do
@behaviour PhxMediaLibrary.Storage
@impl true
def put(path, content, opts) do
# Store content at path
:ok
end
@impl true
def get(path, opts) do
# Return {:ok, binary} or {:error, reason}
end
@impl true
def delete(path, opts) do
:ok
end
@impl true
def exists?(path, opts) do
true
end
@impl true
def url(path, opts) do
"https://my-cdn.com/#{path}"
end
endThen configure it:
config :phx_media_library,
disks: [
custom: [
adapter: MyApp.Storage.CustomAdapter,
# Your adapter-specific options
]
]Error Handling
Functions that can fail return tagged tuples:
case PhxMediaLibrary.to_collection(adder, :images) do
{:ok, media} ->
# Success
{:error, :invalid_mime_type} ->
# File type not accepted by collection
{:error, :file_not_found} ->
# Source file doesn't exist
{:error, changeset} ->
# Ecto validation error
end
# Or use the bang version to raise on error
media = PhxMediaLibrary.to_collection!(adder, :images)Testing
For tests, use the in-memory storage adapter:
# config/test.exs
config :phx_media_library,
repo: MyApp.Repo,
disks: [
local: [
adapter: PhxMediaLibrary.Storage.Memory
]
]
Don't forget to start the memory storage agent in your test_helper.exs:
{:ok, _} = PhxMediaLibrary.Storage.Memory.start_link()Contributing
Contributions are welcome! Please feel free to submit 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
This project is licensed under the MIT License — see the LICENSE file for details.
Acknowledgments
Inspired by Spatie's Laravel Media Library, bringing its excellent developer experience to the Elixir ecosystem.