PhxMediaLibrary - Spatie's Media Library, reimagined for Elixir/Phoenix

Hey, I recently picked up learning Elixir and Phoenix, and this is my first Open Source library, ever. I’ve 10+ years of web dev experience with PHP and Laravel, and I am so used to the human-friendly ecosystem that I tried to translate that same feeling into idiomatic Elixir. I’d love to hear your feedback on how I did!

I did use AI, conversing with it on structure and what would the “Elixir way” entail.

PhxMediaLibrary

Ecto-backed media management library for Elixir and Phoenix, with focus on Developer Experience (DX), inspired by Spatie’s Laravel Media Library.

Quick Look

defmodule MyApp.Post do
  use Ecto.Schema
  use PhxMediaLibrary.HasMedia

  schema "posts" do
    field :title, :string

    has_media()          # all media for this model
    has_media(:images)   # scoped to "images" collection
    has_media(:avatar)   # scoped to "avatar" collection

    timestamps()
  end

  media_collections do
    collection :images, max_files: 20, max_size: 10_000_000
    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
# Add media with a fluent pipeline
{:ok, media} =
  post
  |> PhxMediaLibrary.add("/path/to/photo.jpg")
  |> PhxMediaLibrary.using_filename("hero.jpg")
  |> PhxMediaLibrary.with_custom_properties(%{"alt" => "Hero image"})
  |> PhxMediaLibrary.to_collection(:images)

# Retrieve
PhxMediaLibrary.get_media(post, :images)
PhxMediaLibrary.get_first_media_url(post, :images, :thumb)

# Delete
PhxMediaLibrary.delete(media)
{:ok, count} = PhxMediaLibrary.clear_collection(post, :images)

LiveView — One-Line Uploads

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("save_media", _params, socket) do
    {:ok, media_items} = consume_media(socket, :images, socket.assigns.post, :images)
    {:noreply, stream_media_items(socket, :media, media_items)}
  end
end
<form phx-change="validate" phx-submit="save_media">
  <.media_upload upload={@uploads.images} id="post-images" />
  <button type="submit">Upload</button>
</form>

<.media_gallery media={@streams.media} id="gallery">
  <:item :let={{_id, media}}>
    <.media_img media={media} conversion={:thumb} class="rounded-lg" />
  </:item>
</.media_gallery>

Features

Category What you get
Schema integration Polymorphic has_media() macro, declarative DSL for collections & conversions
Collections MIME validation, file limits, size limits, single-file mode, fallback URLs
Image conversions Thumbnails, resizes, format conversion, responsive srcset — optional, works without libvips
Metadata extraction Auto-extract dimensions, EXIF, format, type classification; stored in metadata JSON field
Remote URLs add_from_url/3 with scheme validation, custom headers, timeout, download telemetry
Storage Local disk, S3, in-memory (tests), or custom adapters via PhxMediaLibrary.Storage behaviour
Streaming uploads Files streamed to storage in 64 KB chunks — never loaded entirely into memory
Direct S3 uploads presigned_upload_url/3 + complete_external_upload/4 for client-to-S3 without proxying
Soft deletes Opt-in deleted_at with restore/1, purge_trashed/2, query scoping, and purge Mix task
Async processing Task (default) or Oban adapter with persistence, retries, and process_sync/2
LiveView Drop-in <.media_upload> and <.media_gallery> components, LiveUpload helpers
Security Content-based MIME detection (50+ formats via magic bytes), SHA-256 checksums
Batch ops clear_collection/2, clear_media/1, reorder/3, move_to/2
Telemetry :start/:stop/:exception spans for add, delete, conversion, storage, batch, download
Errors Tagged tuples + structured exceptions (Error, StorageError, ValidationError)
Queries media_query/2 returns composable Ecto.Query
View helpers <.media_img>, <.responsive_img>, <.picture> components
Mix tasks Install, regenerate conversions, clean orphans, purge deleted, generate migrations
10 Likes

Updated the features and Quick look part with latest published v0.5.0

Nice! Thank you for your contributions.. :grin: