PhxMediaLibrary

Hex.pmHex DocsLicense

A robust, Ecto-backed media management library for Elixir and Phoenix, inspired by Spatie's Laravel Media Library.

Features

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

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

Quick 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
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
    [
      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
end

Upload + 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
end

Template

<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

<.media_gallery> Features

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

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

Conversions

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

Fit 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&#39;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")
end

Responsive 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.install

Regenerate Conversions

mix phx_media_library.regenerate --conversion thumb
mix phx_media_library.regenerate --collection images
mix phx_media_library.regenerate --dry-run

Regenerate Responsive Images

mix phx_media_library.regenerate_responsive
mix phx_media_library.regenerate_responsive --collection images

Clean Orphaned Files

# Dry run — see what would be deleted
mix phx_media_library.clean

# Actually delete
mix phx_media_library.clean --force

Generate Custom Migration

mix phx_media_library.gen.migration add_blurhash_field

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

Then 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&#39;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.

  1. Fork it
  2. Create your feature branch (git checkout -b feature/my-feature)
  3. Commit your changes (git commit -am 'Add my feature')
  4. Push to the branch (git push origin feature/my-feature)
  5. 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.