A robust, Ecto-backed media management library for Elixir and Phoenix.
PhxMediaLibrary provides a simple, fluent API for:
- Associating files with Ecto schemas
- Organizing media into collections
- Storing files across different storage backends (local, S3)
- Generating image conversions (thumbnails, etc.)
- Creating responsive images for optimal loading
Quick Start
- Add
use PhxMediaLibrary.HasMediato your Ecto schema - Run
mix phx_media_library.installto generate the migration - Start associating media with your models!
Example
# In your schema
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)
]
end
end
# Adding media
post
|> PhxMediaLibrary.add("/path/to/image.jpg")
|> PhxMediaLibrary.to_collection(:images)
# Retrieving media
PhxMediaLibrary.get_first_media_url(post, :images)
PhxMediaLibrary.get_first_media_url(post, :images, :thumb)
Summary
Functions
Start adding media to a model from a file path or upload.
Add media from a remote URL.
Get the BlurHash string for a media item, or nil if not generated.
Get a CDN-friendly URL with a cache-busting fingerprint query parameter.
Delete all media in a collection for a model.
Delete all media for a model.
Complete a direct (presigned) upload by creating the Media record.
Delete a media item and its files from storage.
Get a download URL that triggers Content-Disposition: attachment.
Get the first media item for a model in a collection.
Get the URL for the first media item in a collection.
Get all media for a model, optionally filtered by collection.
Get all soft-deleted media for a model, optionally filtered by collection.
Returns an Ecto.Query for all media belonging to a model, optionally
filtered by collection.
Move a single media item to a specific position within its collection.
Get the filesystem path for a media item (local storage only).
Permanently delete a media item and its files from storage.
Generate a presigned URL for direct client-to-storage uploads.
Permanently delete all trashed media for a model that was soft-deleted
before the given DateTime. Removes files from storage and database
records.
Reorder media items in a collection by a list of IDs.
Restore a soft-deleted media item by clearing its deleted_at timestamp.
Get a signed, time-limited URL for a media item.
Soft-delete a media item by setting its deleted_at timestamp.
Get the srcset attribute value for responsive images.
Finalize adding media to a collection.
Same as to_collection/3 but raises on error.
Check whether a media item has been soft-deleted.
Get the URL for a media item, optionally for a specific conversion.
Set a custom filename for the media.
Verify the integrity of a stored media file by comparing its stored checksum against a freshly computed one.
Attach custom properties (metadata) to the media.
Enable responsive image generation for this media.
Disable automatic metadata extraction for this media.
Functions
@spec add(Ecto.Schema.t(), Path.t() | map()) :: PhxMediaLibrary.MediaAdder.t()
Start adding media to a model from a file path or upload.
Returns a MediaAdder struct that can be piped through configuration
functions before finalizing with to_collection/2.
Examples
post
|> PhxMediaLibrary.add("/path/to/file.jpg")
|> PhxMediaLibrary.to_collection(:images)
@spec add_from_url(Ecto.Schema.t(), String.t(), keyword()) :: PhxMediaLibrary.MediaAdder.t()
Add media from a remote URL.
The file will be downloaded and stored locally before processing.
URL validation ensures only http and https schemes are accepted.
Options
:headers— custom request headers (e.g.[{"Authorization", "Bearer token"}]):timeout— download timeout in milliseconds
Telemetry
Downloads emit [:phx_media_library, :download, :start | :stop | :exception]
events with URL, size, and MIME type metadata.
Examples
post
|> PhxMediaLibrary.add_from_url("https://example.com/image.jpg")
|> PhxMediaLibrary.to_collection(:images)
# With authentication
post
|> PhxMediaLibrary.add_from_url("https://api.example.com/file.pdf",
headers: [{"Authorization", "Bearer my-token"}])
|> PhxMediaLibrary.to_collection(:documents)
@spec blurhash(PhxMediaLibrary.Media.t()) :: String.t() | nil
Get the BlurHash string for a media item, or nil if not generated.
Blurhash must be opted in via config :phx_media_library, responsive_images: [blurhash: true] and the :image library must be available.
Use the <PhxMediaLibrary.Components.blurhash> component to render the
decoded placeholder client-side via a <canvas> element.
Examples
PhxMediaLibrary.blurhash(media)
#=> "LKO2?V%2Tw=w]~RBVZRi};RPxuwH"
@spec cdn_url(PhxMediaLibrary.Media.t(), atom() | nil) :: String.t()
Get a CDN-friendly URL with a cache-busting fingerprint query parameter.
Appends ?v={checksum[0..7]} when the media item has a stored checksum so
CDN edges pick up the new file whenever it is replaced. Falls back to a
plain URL when no checksum is stored.
Equivalent to PhxMediaLibrary.url(media, conversion, cache_bust: true).
Examples
PhxMediaLibrary.cdn_url(media)
#=> "/uploads/images/1/uuid/photo.jpg?v=a1b2c3d4"
@spec clear_collection(Ecto.Schema.t(), atom()) :: {:ok, non_neg_integer()} | {:error, term()}
Delete all media in a collection for a model.
Deletes files from storage for each item, then removes all matching database records in a single query. This is significantly more efficient than deleting one-by-one for large collections.
Examples
PhxMediaLibrary.clear_collection(post, :images)
@spec clear_media(Ecto.Schema.t()) :: {:ok, non_neg_integer()} | {:error, term()}
Delete all media for a model.
Deletes files from storage for each item, then removes all matching database records in a single query.
Examples
PhxMediaLibrary.clear_media(post)
@spec complete_external_upload(Ecto.Schema.t(), atom(), String.t(), keyword()) :: {:ok, PhxMediaLibrary.Media.t()} | {:error, term()}
Complete a direct (presigned) upload by creating the Media record.
Call this after the client has finished uploading directly to storage. The file is already stored — this function only creates the database record and optionally triggers conversions.
Required options
:filename— original filename:content_type— MIME type of the uploaded file:size— file size in bytes
Optional options
:disk— storage disk (must match the one used forpresigned_upload_url/3):custom_properties— user-defined metadata map:checksum— pre-computed checksum (e.g. from client-side hashing):checksum_algorithm— algorithm used (default:"sha256")
Examples
{:ok, media} =
PhxMediaLibrary.complete_external_upload(post, :images, upload_key,
filename: "photo.jpg",
content_type: "image/jpeg",
size: 45_000
)
@spec delete(PhxMediaLibrary.Media.t()) :: :ok | {:ok, PhxMediaLibrary.Media.t()} | {:error, term()}
Delete a media item and its files from storage.
@spec download_url(PhxMediaLibrary.Media.t(), atom() | nil, keyword()) :: String.t()
Get a download URL that triggers Content-Disposition: attachment.
For S3 storage this generates a presigned GET URL with the
response-content-disposition query parameter included in the AWS
Signature V4 canonical request.
For local disk storage the URL routes through
PhxMediaLibrary.Plug.MediaDownload, which serves the file with the
proper header. The plug must be mounted in the app's router.
Options
:filename— override the filename in the header (default:media.file_name).:expires_in— presigned URL expiry in seconds for S3 (default:3600).
Examples
# In a template
<a href={PhxMediaLibrary.download_url(@media)} download>Download</a>
@spec get_first_media(Ecto.Schema.t(), atom()) :: PhxMediaLibrary.Media.t() | nil
Get the first media item for a model in a collection.
@spec get_first_media_url(Ecto.Schema.t(), atom(), atom() | keyword()) :: String.t() | nil
Get the URL for the first media item in a collection.
Examples
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")
@spec get_first_media_url(Ecto.Schema.t(), atom(), atom() | nil, keyword()) :: String.t() | nil
@spec get_media(Ecto.Schema.t(), atom() | nil) :: [PhxMediaLibrary.Media.t()]
Get all media for a model, optionally filtered by collection.
Examples
PhxMediaLibrary.get_media(post)
PhxMediaLibrary.get_media(post, :images)
@spec get_trashed_media(Ecto.Schema.t(), atom() | nil) :: [PhxMediaLibrary.Media.t()]
Get all soft-deleted media for a model, optionally filtered by collection.
This is the inverse of get_media/2 — it returns only trashed items.
Examples
PhxMediaLibrary.get_trashed_media(post)
PhxMediaLibrary.get_trashed_media(post, :images)
@spec media_query(Ecto.Schema.t(), atom() | nil) :: Ecto.Query.t()
Returns an Ecto.Query for all media belonging to a model, optionally
filtered by collection.
This is useful for composing queries with Ecto — you can add further
filters, selects, limits, or use it with Repo.all/1, Repo.one/1, etc.
Examples
# All media for a post
PhxMediaLibrary.media_query(post)
|> Repo.all()
# Only images
PhxMediaLibrary.media_query(post, :images)
|> Repo.all()
# Compose with further constraints
PhxMediaLibrary.media_query(post, :images)
|> where([m], m.mime_type == "image/png")
|> Repo.all()
@spec move_to(PhxMediaLibrary.Media.t(), pos_integer()) :: {:ok, PhxMediaLibrary.Media.t()} | {:error, term()}
Move a single media item to a specific position within its collection.
Shifts other items' order_column values to make room, then sets the
target item's position. Position is 1-based.
Examples
PhxMediaLibrary.move_to(media, 1) # move to first position
PhxMediaLibrary.move_to(media, 3) # move to third position
@spec path(PhxMediaLibrary.Media.t(), atom() | nil) :: String.t() | nil
Get the filesystem path for a media item (local storage only).
@spec permanently_delete(PhxMediaLibrary.Media.t()) :: :ok | {:error, term()}
Permanently delete a media item and its files from storage.
This always performs a hard delete regardless of whether soft deletes are enabled. Use this for force-deleting or cleaning up trashed items.
@spec presigned_upload_url(Ecto.Schema.t(), atom(), keyword()) :: {:ok, String.t(), map(), String.t()} | {:error, term()}
Generate a presigned URL for direct client-to-storage uploads.
This allows the client (browser) to upload files directly to a remote
storage backend (e.g. S3) without proxying through the server. The server
only generates the signed URL and, after the upload completes, creates the
Media record via complete_external_upload/4.
Returns {:ok, url, fields, upload_key} on success, where:
url— the presigned upload endpointfields— a map of form fields for POST-based uploads (empty for PUT)upload_key— the storage path to pass back tocomplete_external_upload/4
Returns {:error, :not_supported} if the storage adapter doesn't support
presigned URLs (e.g. local disk storage).
Options
:disk— storage disk to use (default: collection or global default):filename— the filename for the upload (required):content_type— expected MIME type of the upload:expires_in— URL expiration in seconds (default: 3600):max_size— maximum upload size in bytes
Examples
{:ok, url, fields, key} =
PhxMediaLibrary.presigned_upload_url(post, :images,
filename: "photo.jpg",
content_type: "image/jpeg",
expires_in: 600
)
# Client uploads directly to `url` with `fields`
# Then server completes:
{:ok, media} =
PhxMediaLibrary.complete_external_upload(post, :images, key,
filename: "photo.jpg",
content_type: "image/jpeg",
size: 123_456
)
@spec purge_trashed( Ecto.Schema.t(), keyword() ) :: {:ok, non_neg_integer()} | {:error, term()}
Permanently delete all trashed media for a model that was soft-deleted
before the given DateTime. Removes files from storage and database
records.
When called without a cutoff, permanently deletes all trashed media for the model.
Examples
# Delete everything trashed more than 30 days ago
cutoff = DateTime.add(DateTime.utc_now(), -30, :day)
{:ok, count} = PhxMediaLibrary.purge_trashed(post, before: cutoff)
# Delete all trashed media for the model
{:ok, count} = PhxMediaLibrary.purge_trashed(post)
@spec reorder(Ecto.Schema.t(), atom(), [String.t()]) :: {:ok, non_neg_integer()} | {:error, term()}
Reorder media items in a collection by a list of IDs.
Sets the order_column for each media record according to its position
in the given ID list. Uses a single database transaction with individual
updates for correctness.
IDs not present in the collection are silently ignored. Media items in the collection whose IDs are not in the list keep their current order but are shifted after the explicitly ordered items.
Examples
# Set explicit order: id3 first, id1 second, id2 third
PhxMediaLibrary.reorder(post, :images, [id3, id1, id2])
@spec restore(PhxMediaLibrary.Media.t()) :: {:ok, PhxMediaLibrary.Media.t()} | {:error, Ecto.Changeset.t()}
Restore a soft-deleted media item by clearing its deleted_at timestamp.
Examples
{:ok, restored} = PhxMediaLibrary.restore(media)
restored.deleted_at #=> nil
@spec signed_url(PhxMediaLibrary.Media.t(), atom() | nil, keyword()) :: String.t()
Get a signed, time-limited URL for a media item.
For S3 this is an AWS Signature V4 presigned GET URL. For local disk
this is an HMAC-SHA256–signed URL verified by
PhxMediaLibrary.Plug.MediaDownload. A secret_key_base must be present in
the disk config and the plug must be mounted.
Options
:expires_in— seconds until the URL expires (default:3600).:download— also add aContent-Disposition: attachmentheader.
Examples
PhxMediaLibrary.signed_url(media)
PhxMediaLibrary.signed_url(media, nil, expires_in: 300)
PhxMediaLibrary.signed_url(media, nil, download: true, expires_in: 600)
@spec soft_delete(PhxMediaLibrary.Media.t()) :: {:ok, PhxMediaLibrary.Media.t()} | {:error, Ecto.Changeset.t()}
Soft-delete a media item by setting its deleted_at timestamp.
The record remains in the database but is excluded from all queries
by default. Files are not removed from storage. Call
permanently_delete/1 to remove files and the database record.
Examples
{:ok, trashed} = PhxMediaLibrary.soft_delete(media)
trashed.deleted_at #=> ~U[2026-02-27 17:00:00Z]
@spec srcset(PhxMediaLibrary.Media.t(), atom() | nil) :: String.t() | nil
Get the srcset attribute value for responsive images.
@spec to_collection(PhxMediaLibrary.MediaAdder.t(), atom(), keyword()) :: {:ok, PhxMediaLibrary.Media.t()} | {:error, term()}
Finalize adding media to a collection.
Options
:disk- Override the storage disk for this media
Examples
post
|> PhxMediaLibrary.add(upload)
|> PhxMediaLibrary.to_collection(:images)
post
|> PhxMediaLibrary.add(upload)
|> PhxMediaLibrary.to_collection(:images, disk: :s3)
@spec to_collection!(PhxMediaLibrary.MediaAdder.t(), atom(), keyword()) :: PhxMediaLibrary.Media.t()
Same as to_collection/3 but raises on error.
Raises PhxMediaLibrary.Error if the operation fails.
@spec trashed?(PhxMediaLibrary.Media.t()) :: boolean()
Check whether a media item has been soft-deleted.
Examples
PhxMediaLibrary.trashed?(media) #=> false
{:ok, trashed} = PhxMediaLibrary.soft_delete(media)
PhxMediaLibrary.trashed?(trashed) #=> true
@spec url(PhxMediaLibrary.Media.t(), atom() | nil, keyword()) :: String.t()
Get the URL for a media item, optionally for a specific conversion.
Accepts the same options as PhxMediaLibrary.UrlGenerator.url/3, including
cache_bust: true, signed: true, download: true, and expires_in.
@spec using_filename(PhxMediaLibrary.MediaAdder.t(), String.t()) :: PhxMediaLibrary.MediaAdder.t()
Set a custom filename for the media.
@spec verify_integrity(PhxMediaLibrary.Media.t()) :: :ok | {:error, :checksum_mismatch | :no_checksum | term()}
Verify the integrity of a stored media file by comparing its stored checksum against a freshly computed one.
Returns :ok if the checksums match, {:error, :checksum_mismatch} if
they differ, or {:error, :no_checksum} if no checksum was stored.
Examples
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")
end
@spec with_custom_properties(PhxMediaLibrary.MediaAdder.t(), map()) :: PhxMediaLibrary.MediaAdder.t()
Attach custom properties (metadata) to the media.
@spec with_responsive_images(PhxMediaLibrary.MediaAdder.t()) :: PhxMediaLibrary.MediaAdder.t()
Enable responsive image generation for this media.
@spec without_metadata(PhxMediaLibrary.MediaAdder.t()) :: PhxMediaLibrary.MediaAdder.t()
Disable automatic metadata extraction for this media.
By default, PhxMediaLibrary extracts metadata (dimensions, EXIF, etc.) from uploaded files. Use this to skip extraction for a specific upload.
Examples
post
|> PhxMediaLibrary.add(upload)
|> PhxMediaLibrary.without_metadata()
|> PhxMediaLibrary.to_collection(:images)