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 |






















