We can't find the internet
Something went wrong!
Waffle and Phoenix LiveView

- Created: 03/10/2025
- Last updated: 03/10/2025
Intro
It has taken me a few goes to get to an integration of Waffle, Phoenix, and live view which I am happy with. And as I have tried many times to find a specific guide for this, and failed, I thought I would share my findings.
There is a link to a git repo at the end where you can find a working example complete with Docker compose setup to make sure you can see it running. Or follow along for my step by step guide.
So that we can get straight to the point, I am going to assume that you have experience working with Phoenix framework and programming in Elixir.
What is this about?
Automated management of image uploads is a common requirement in web development. This can be creating different versions of an image, intended for separate contexts e.g. thumbnail, and full size version, or perhaps it is a performance optimization, rendering responsive images making your pages load faster.
I need an efficient, flexible means of handling these image uploads, in a way that can (mostly) be re-used from project to project. That is what we are going to develop in the guide below.
We’ll use Phoenix LiveView for the user interface and Waffle for file processing. Waffle is a flexible file upload library for Elixir with straightforward integrations for Amazon S3 and ImageMagick. We will use it to process images uploaded via our Phoenix LiveView form. This will include:
- validate image files once they are uploaded to the server.
- automate image manipulation and compression with ImageMagick
- convert images to webp format with ImageMagick
- create multiple versions of each uploaded image,
- derive versioned urls for each image file as needed.
The combination of waffle and Imagemagick is very flexible, you can do a lot more with it, like make complex image manipulations, or provide a default images when none have been uploaded. It’s worth having a read about waffle and ImageMagick if you haven’t already.
Project spec
To demonstrate this setup, we will be creating the following:
- We will create a toy blog application to demonstrate the integration of Phoenix, Ecto, Waffle, and ImageMagick.
- This will allow users to create a blog post, which requires an image upload.
- The image will be converted to webp, cropped, resized and compressed as multiple versions are saved.
- So that image urls can be derived, they will be saved in a folder named with the post uuid. Ecto.Multi will be needed to achieve this.
- The image will be validated first browser side, and then again on the server. Validation error messages will be fed back to our form to communicate this clearly with the user.
- Once a blog post is successfully created, it can be viewed with a responsive image. Each of the image versions will be rendered depending on the window size.
- Users can edit and delete blog posts along with their images.
Getting started
To get us straight to a point where we can work with file uploads, I will rush through the project setup steps. There are many other guides which will explain this part in more detail if you want that.
This section is provided to make sure that following steps and accompanying explanations make sense, it is not intended to be a ‘My first blog’ tutorial, nor is it intended to be a complete or usable blog application.
If you like you could skip to the waffle integration here
In a fresh dev container (see project repo, link at end):
mix archive.install hex phx_new
mix phx.new images --binary-id
# Update postgres connection details in `config/dev.exs` and `config/test.exs`
mix ecto.create
mix test
# Generate the shell of our toy blog
mix phx.gen.live Blog Article articles title:string content:string
Before following the instructions in the command output, I updated my migration to ensure that article title and content are required:
#...
add :title, :string, null: false
add :content, :string, null: false
#...
Now, follow instructions from command output:
- paste routes into router
- migrate database
mix ecto.migrate
mix test
git init
git add .
git commit -m "initial commit"
iex -S mix phx.server
# Open in your browser:
# http://localhost:4000/articles
Simple enough. Now we have the husk of a toy blogging application to play with.
Add image to Article
Before we touch the user interface, we will set up Waffle, and imagemagick, along with a few other dependencies, and ensure that we have a working integration with Ecto. By the end of this step, our articles will require images, and we will have working seeds and tests to prove that they are handled correctly. However, our article form will not work… that will be seen to later on.
First we will add some dependencies. In mix.exs
we will add the following to the deps list:
#...
{:waffle_ecto, "~> 0.0.12"},
{:ex_image_info, "~> 1.0"}
#...
Give it a mix deps.get
.
And add some configuration for waffle in config/config.exs
. For this demo project, were using local storage.
config :waffle,
storage: Waffle.Storage.Local,
asset_host: "http://localhost:4000"
waffle_ecto
will handle our integration of Ecto and Waffle. It depends on waffle, so that will be installed at the same time.
We will use ex_image_info
for server side image validations.
Also, you will need to install imagemagick, in my devcontainer, I add apt install imagemagick
to my [Dockerfile]. Installation steps for this will vary per dev environment. See https://imagemagick.org/script/download.php. The important thing is that there is an imagemagick binary in your path.
Second, we will generate and run a migration to add an image field to our article schema:
mix ecto.gen.migration add_image_to_articles
#...
alter table(:articles) do
add :image, :string
end
#...
This is a string field for waffle_ecto
to hold image data in. It can be null as we cannot fill this in before the article has been saved with its generated uuid. This 2 step process is covered below.
Now update the article schema. Add Waffle.Ecto.Schema, the image field, and a second changeset:
# In lib/images/blog/article.ex
# Add this line
use Waffle.Ecto.Schema
# Add to your schema block
field :image, :string
# Add this new changeset function
def image_changeset(article, attrs) do
article
|> cast_attachments(attrs, [:image])
|> validate_required([:image])
end
Now that this is in place, we can create our image definition. This file handles server side image validation, processing of image versions, keeps the original in-case we want to do additional processing later, and derivation of resulting file urls. This is where out app integrates with imagemagick.
Create lib/images/blog/article/image.ex
its not short, I provided a more than minimal example for you, which makes it a bit of a tangent, so you will have to go to the project repository.
Now that we have the ability to process uploaded files, and to associate them with their article resource, we can update the Blog context to make use of this new functionality:
Create lib/images/blog/multis.ex
This enables us to have multi step processes, wrapped up in a database transaction. We use them here to ensure that the article has an id before creating image versions, and to delete image files along with the article.
The full file can be seen here: https://gitlab.com/nathamanath/images/-/blob/main/lib/images/blog/multis.ex?ref_type=heads
Then in lib/images/blog.ex
make use of these multis:
# ...
def create_article(attrs) do
attrs
|> Multis.create_resource_multi()
|> Repo.transaction()
|> parse_multi_result()
end
# ...
def update_article(%Article{} = article, attrs) do
article
|> Multis.update_article_multi(attrs)
|> Repo.transaction()
|> parse_multi_result()
end
# ...
def delete_article(%Article{} = article) do
article
|> Multis.delete_article_multi()
|> Repo.transaction()
|> parse_multi_result()
end
defp parse_multi_result({:ok, %{article_image: article}}), do: {:ok, article}
defp parse_multi_result({:ok, %{article: article}}), do: {:ok, article}
defp parse_multi_result({:error, _, changeset, _}), do: {:error, changeset}
# ...
To keep this from becoming massive, were skipping tests. You should have some. We will demonstrate this working by seeding an article with an image:
alias Images.Blog
fixture_path = Path.join([File.cwd!(), "test", "support", "fixtures", "1600x900.png"])
{:ok, _article} =
Blog.create_article(%{
title: "First Post",
content: "The post that hurts the most.",
image: %Plug.Upload{
content_type: "image/webp",
path: fixture_path,
filename: Path.basename(fixture_path)
}
})
Then run the seeds:
mix run priv/repo/seeds.exs
Have a look in uploads
, you should see some image uploads.
And look in the database to see the image column has been populated.
iex(1)> [article] = Blog.list_articles
#...
[
%Images.Blog.Article{
__meta__: #Ecto.Schema.Metadata<:loaded, "articles">,
id: "76294421-c5d4-4707-b833-0441803c6b1a",
title: "First Post",
content: "The post that hurts the most.",
image: %{file_name: "1600x900.png", updated_at: ~N[2025-10-03 15:55:10]},
inserted_at: ~U[2025-10-03 15:55:09Z],
updated_at: ~U[2025-10-03 15:55:10Z]
}
]
and try deleting the article via iex:
iex(2)> Blog.delete_article article
#...
{:ok,
%Images.Blog.Article{
__meta__: #Ecto.Schema.Metadata<:deleted, "articles">,
id: "76294421-c5d4-4707-b833-0441803c6b1a",
title: "First Post",
content: "The post that hurts the most.",
image: %{file_name: "1600x900.png", updated_at: ~N[2025-10-03 15:55:10]},
inserted_at: ~U[2025-10-03 15:55:09Z],
updated_at: ~U[2025-10-03 15:55:10Z]
}}
You should now see that the upload images are deleted along with their parent directory.
That was plenty, time for a git commit.
git add .
git commit -m "add waffle"
Rendering responsive images
Now that we have a seeded article with versioned image uploads, lets render them in lib/images_web/live/article_live/show.ex
as a responsive image.
#...
<:item title="Image">
<img
src={Image.src(@article, :m)}
alt={@article.title}
srcset={Image.srcset(@article)}
sizes="(max-width: 800px) 90vw, 800px"
/>
</:item>
#...
And update endpoint.ex
to allow plug.static to serve our images from /uploads
. This can be configured with an environment variable at runtime:
#...
# Waffle uploads dir available at `/uploads`
plug Plug.Static,
at: "/uploads",
from: Application.get_all_env(:images)[:uploads_dir] || "uploads",
gzip: false
#...
Nice! We have a responsive image! Give it a check in your browser.
git add .
git commit -m "responsive-image"
Attach it to LiveView form
Now the last step in this process is to integrate our LiveView form in lib/images_web/live/article_live/form.ex
We need to add user interface components, add functions to translate validation errors into human readable messages,
For this we are following steps from this official guide with a few modifications:
# Our upload is called `image` not `avatar`
# Only one image per article for us
# ...
|> allow_upload(:image,
accept: ~w(.jpg .jpeg .png .webp),
max_entries: 1,
max_file_size: 10_000_000
)
# ...
And the main difference is how we consume uploaded files.
defp consume_uploaded_images(socket, resource_params) do
uploaded_files =
consume_uploaded_entries(socket, :image, fn %{path: path}, entry ->
# Waffle wants a file extension, temp files dont have one by default
path_with_extension = path <> String.replace(entry.client_type, "image/", ".")
File.cp!(path, path_with_extension)
# Waffle expects a Plug.Upload struct
plug_upload = %Plug.Upload{
path: path_with_extension,
content_type: entry.client_type,
filename: entry.client_name
}
{:ok, plug_upload}
end)
case uploaded_files do
[plug_upload] -> Map.put(resource_params, "image", plug_upload)
[] -> resource_params
end
end
This is re-naming the temp file, giving it the appropriate file extension, then mapping upload temp files to Plug.Upload structs and assigning them to the image property of our params map. It is used like so in save_article/3
:
defp save_article(socket, :edit, article_params) do
case Blog.update_article(
socket.assigns.article,
consume_uploaded_images(socket, article_params)
) do
# ...
defp save_article(socket, :new, article_params) do
case Blog.create_article(consume_uploaded_images(socket, article_params)) do
# ...
This way, by the time article_params gets to Blog.create_article/1 or Blog.update_article/2, they have all the information needed for our existing waffle integration to take over. One of the most lovely parts of this is that server side validation errors come back in a changeset and are displayed inline in the form.
The full file can be seen here: form.ex
git add .
git commit -m "form"
Conclusion
Im pleased with that, waffle is now working seamlessly with the Phoenix application. This makes for nice flexible manageable image uploads. Its easy enough to paste into new projects and provides an expandable, scalable solution to this common web application feature.
Show me the code
https://gitlab.com/nathamanath/images