This guide walks you through installing PhxMediaLibrary, configuring storage, and adding your first media files.
Installation
Add phx_media_library to your dependencies in mix.exs:
def deps do
[
{:phx_media_library, "~> 0.6.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.
Then fetch dependencies:
mix deps.get
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"}See the Storage guide for custom adapters and advanced configuration.
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.ObanRun the Installer
mix phx_media_library.install
mix ecto.migrate
This generates the media table migration with all required fields.
Tailwind CSS Setup
PhxMediaLibrary ships with styled LiveView components that use Tailwind CSS
utility classes. For Tailwind v4 to detect and include these classes in your
CSS bundle, add the library's source path to your assets/css/app.css:
@import "tailwindcss" source(none);
@source "../css";
@source "../js";
@source "../../lib/my_app_web";
/* PhxMediaLibrary — include both paths to support Hex deps and path deps.
Tailwind v4 silently ignores paths that don't exist. */
@source "../../deps/phx_media_library/lib";
@source "../../../phx_media_library/lib";Why two
@sourcelines? When installed from Hex, the library lives indeps/phx_media_library/lib/. When used as a path dependency (e.g.{:phx_media_library, path: "../phx_media_library"}), it lives outside your project'sdeps/folder. Tailwind v4 silently skips any@sourcepath that doesn't exist, so including both ensures the classes are scanned regardless of how the dependency is consumed.
Define Your Schema
PhxMediaLibrary supports two styles for defining collections and conversions. You can use either — or mix them.
Declarative DSL — nested style (recommended)
Nest convert calls inside collection ... do ... end blocks so it's
immediately clear which conversions apply to which collections. Collections
without image content (like :documents) omit the do block — no
conversions will run for those uploads:
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 do
convert :thumb, width: 150, height: 150, fit: :cover
convert :preview, width: 800, quality: 85
convert :banner, width: 1200, height: 400, fit: :crop
end
# No conversions for documents — PDFs are stored as-is
collection :documents, accepts: ~w(application/pdf text/plain)
collection :avatar, single_file: true, fallback_url: "/images/default.png" do
convert :thumb, width: 150, height: 150, fit: :cover
end
end
endDeclarative DSL — flat style
Define collections and conversions in separate blocks. Always use the
:collections option to scope conversions explicitly — without it, a
conversion runs for every collection (including non-image ones like
documents, which will cause processing errors):
defmodule MyApp.Post do
use Ecto.Schema
use PhxMediaLibrary.HasMedia
schema "posts" do
field :title, :string
has_media()
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, collections: [:images, :avatar]
convert :preview, width: 800, quality: 85, collections: [:images]
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
[
# Always scope conversions to specific collections
conversion(:thumb, width: 150, height: 150, fit: :cover, collections: [:images, :avatar]),
conversion(:preview, width: 800, quality: 85, collections: [:images])
]
end
endThe 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.
Add Media
# 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)
# From an authenticated URL
{:ok, media} =
post
|> PhxMediaLibrary.add_from_url("https://api.example.com/files/123.pdf",
headers: [{"Authorization", "Bearer my-token"}],
timeout: 15_000)
|> PhxMediaLibrary.to_collection(:documents)
# Bang version raises on error
media = PhxMediaLibrary.to_collection!(adder, :images)URL downloads validate the scheme (
http/httpsonly), rejectftp://andfile://URLs, and automatically store the source URL incustom_properties["source_url"].
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()Metadata Extraction
PhxMediaLibrary automatically extracts metadata from uploaded files and stores
it in the metadata field:
{:ok, media} =
post
|> PhxMediaLibrary.add("/path/to/photo.jpg")
|> PhxMediaLibrary.to_collection(:images)
media.metadata
# => %{
# "type" => "image",
# "format" => "jpeg",
# "width" => 1920,
# "height" => 1080,
# "has_alpha" => false,
# "exif" => %{"orientation" => 1, ...},
# "extracted_at" => "2026-02-27T16:00:00Z"
# }Image dimensions and EXIF data require the optional :image dependency.
Without it, you still get type classification and format detection.
To skip extraction for a specific upload:
post
|> PhxMediaLibrary.add(upload)
|> PhxMediaLibrary.without_metadata()
|> PhxMediaLibrary.to_collection(:images)Or disable globally:
config :phx_media_library, extract_metadata: falseSee the Collections & Conversions guide for details on custom extractors and supported metadata fields.
Delete Media
# Delete a single media item (removes files from storage too)
PhxMediaLibrary.delete(media)
# Clear all media in a collection (batch-optimized, single DELETE query)
{:ok, count} = PhxMediaLibrary.clear_collection(post, :images)
# Clear all media for a model (batch-optimized)
{:ok, count} = PhxMediaLibrary.clear_media(post)Next Steps
- Collections & Conversions — Validation rules, image processing, responsive images, metadata extraction
- LiveView Integration — Drop-in upload and gallery components
- Storage — Multiple backends, custom adapters
- Error Handling — Tagged tuples, custom exceptions, MIME detection
- Telemetry — Monitoring and observability events (including download events)
- Advanced Usage — Reordering, mix tasks, Oban setup, testing strategies