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"}
  ]
end

Note: The :image dependency (libvips) is optional. PhxMediaLibrary works for file storage (PDFs, CSVs, documents) without it. Image conversions and responsive images require :image to 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.Oban

Run 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 @source lines? When installed from Hex, the library lives in deps/phx_media_library/lib/. When used as a path dependency (e.g. {:phx_media_library, path: "../phx_media_library"}), it lives outside your project's deps/ folder. Tailwind v4 silently skips any @source path 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.

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
end

Declarative 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
end

Function-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
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.

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/https only), reject ftp:// and file:// URLs, and automatically store the source URL in custom_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: false

See 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