DEV Community

Cover image for (Unofficial) Getting Started with Elixir Phoenix Guide
Andy Klimczak
Andy Klimczak

Posted on

(Unofficial) Getting Started with Elixir Phoenix Guide

Hey, this guide is meant to be a recreation of the Getting Started with Rails Guide, but for Elixir Phoenix. I very intentionally poach their words for sections when applicable. All true credit goes to the writer of that Rails guide. Thank you for creating such an awesome guide.

This is my attempt at a guide that I wish I had that attempts to match and implements the same things as the Rails guide, but in Phoenix.

If there are better/simpler ways to do something, please create an issue or PR. You'll help me understand how to write better Phoenix, and others as well.

Thank you! Let's go.

1 Guide Assumptions

This guide is designed for beginners who want to get started with creating a Phoenix application from scratch.
It does not assume that you have any prior experience with Phoenix.

Phoenix is a web application framework running on the Elixir programming language. If you have no prior experience with Elixir, you will find a very steep learning curve diving straight into Phoenix. There are several curated lists of online resources for learning Phoenix:

2 What is Phoenix?

Phoenix is a web development framework written in Elixir which implements the server-side Model View Controller (MVC) pattern. Many of its components and concepts will seem familiar to those of us with experience in other web frameworks like Ruby on Rails or Python's Django.

Phoenix provides the best of both worlds - high developer productivity and high application performance. It also has some interesting new twists like channels for implementing realtime features and pre-compiled templates for blazing speed.

source

3 Creating a new Phoenix Project

The best way to read this guide is to follow it step by step. All steps are essential to run this example application and no additional code or steps are needed.

By following along with this guide, you'll create a Phoenix project called blog, a (very) simple weblog. Before you can start building the application, you need to make sure that you have Phoenix itself installed.

3.1 Installing Phoenix

Official Phoenix Install Guide

Prerequisites:

  • elixir
  • SQLite3
3.1.1 Installing Elixir

Verify that you have a current version of Elixir installed:

$ elixir -v
Erlang/OTP 27 [erts-15.0] [source] [64-bit] [smp:20:20] [ds:20:20:10] [async-threads:1] [jit:ns]

Elixir 1.17.1 (compiled with Erlang/OTP 27)
Enter fullscreen mode Exit fullscreen mode

Phoenix requires a Elixir version 1.14.1/Erlang 24 or later.

For installation methods, check out the official docs.

3.1.2 Installing SQLite3

You will also need an installation of SQLite3.

Verify that is correctly installed and in your load PATH:

$ sqlite3 --version
Enter fullscreen mode Exit fullscreen mode
3.1.3 Installing Phoenix

To install Phoenix, use the mix command:

$ mix archive.install hex phx_new
Enter fullscreen mode Exit fullscreen mode

To verify Phoenix was installed correctly, run the command:

mix phx.new
Enter fullscreen mode Exit fullscreen mode

3.2 Creating the Blog Application

Phoenix comes with a number of scripts called generators that are designed to make development easier and quicker by creating files with boilerplate code. One of these is the new application generator, which will provide you with a foundation of a fresh Phoenix application so that you don't have to write it yourself.

To use this generator, open a terminal, navigate to a directory, and run:

$ mix phx.new blog --database sqlite3
Enter fullscreen mode Exit fullscreen mode

This will create a Phoenix application called Blog in a blog directory and install all dependencies that are already in the mix.exs file using mix deps.get.

โš ๏ธ You can see all the command line options the Phoenix application generator accepts by running mix phx.new

After you create the blog application, switch to its directory:

$ cd blog
Enter fullscreen mode Exit fullscreen mode

The blog directory will have a number of generated files and folder that make up a structure of a Phoenix application. Most of the work of this tutorial will happen in the lib folder, but here's a basic rundown of each of the files and folders that Phoenix creates by default:

File/Folder Purpose
assets/ Contains your css and javascript assets for your application.
config/ General and environment specific configuration for your application.
lib/ Contains your contexts, schemas, controllers, views of your application. You'll focus on this directory for the remainder of this guide.
priv/ Contains your I18n translations, database migrations, and static assets.
test/ Unit tests, fixtures, and other test files
.formatter.exs Config file for Elixir code formatting. See more here.
.gitignore Default .gitignore file for Phoenix applications to not commit generated files to git repositories.
mix.exs Used to specify the main configuration for the project, application, and dependencies.
README.md Standard README that details how to run a Phoenix application.

4 Hello Phoenix

To begin with, let's get some text on the screen quickly. To do this, you'll need your Phoenix application server running.

4.1 Starting Up the Web Server

You actually have a functional Phoenix application already.
To see it, you need to start a web server on your development machine. But first we need to create and migrate the database. You can do this by running the following commands in the blog directory:

$ mix ecto.create
$ mix ecto.migrate
Enter fullscreen mode Exit fullscreen mode

Then start the server with:

$ mix phx.server
Enter fullscreen mode Exit fullscreen mode

To see your application in action, open the browser window and navigate to http://localhost:4000. You should see the default Phoenix information page.

To stop the server, hit Ctrl-C in the terminal window. In the development environment, Phoenix does not generally require you to restart the server; changes you make in files will be automatically picked up by the server.

4.2 Say "Hello", Phoenix

To get Phoenix saying "Hello", you need to create at minimum a route, a controller with an action, and a view. A route maps a request to a controller action. A controller action performs the necessary work to handle the request, and prepares any data for the view. A view displays data in a desired format.

Let's start by adding a route to our routes file, lib/blog_web/router.ex at the bottom of the scope "/", BlogWeb do block:

scope "/", BlogWeb do
    pipe_through :browser

    get "/", PageController, :home
    get "/articles", ArticleController, :index
end
Enter fullscreen mode Exit fullscreen mode

The route above declares that GET /articles requests are mapped to the index action of ArticleController.

Let's create the ArticleController at lib/blog_web/controllers/article_controller.ex with our index action next:

defmodule BlogWeb.ArticleController do
  use BlogWeb, :controller

  def index(conn, _params) do
    render(conn, :index)
  end
end
Enter fullscreen mode Exit fullscreen mode

Next let's create an HTML view, which gets collocated with the controller in lib/blog_web/controllers/article_html.ex:

defmodule BlogWeb.ArticleHTML do
  use BlogWeb, :html

  embed_templates "article_html/*"
end
Enter fullscreen mode Exit fullscreen mode

Then finally create the HTML template in lib/blog_web/controllers/article_html/index.html.heex:

<h1 class="text-lg text-brand">
    Hello Phoenix
</h1>
Enter fullscreen mode Exit fullscreen mode

Visit http://localhost:4000/articles to see Phoenix display "Hello Phoenix"!

4.3 Setting the Application to Home Page

At the moment, http://localhost:4000 still displays the default Phoenix page. Let's display our "Hello, Phoenix!" text at http://localhost:4000 as well. To do so, we will add a route that maps the root path of our application to the appropriate controller and action.

Let's open lib/blog_web/router.ex and add the get "/" path to map to the ArticleController index action:

scope "/", BlogWeb do
    pipe_through :browser

    get "/", ArticleController, :index
    get "/articles", ArticleController, :index
end
Enter fullscreen mode Exit fullscreen mode

5 Autoloading

TODO?

6 MVC and You

So far, we've discussed routes, controllers, actions, and views. All of these are typical pieces of a web application that follows the MVC (Model-View-Controller) pattern. MVC is a design pattern that divides the responsibilities of an application to make it easier to reason about. Phoenix follows this design pattern by convention.

Since we have a controller and a view to work with, let's generate the next piece: a model.

6.1 Generating a model

The model in Phoenix is actually an Ecto Schema. Schemas behave similarly to models from other frameworks, such as mapping external data into Elixir structs. But the difference to other frameworks is that schemas area much more solely focused on that mapping of external data.

To generate a schema, we'll use the schema generator to generate an article schema which contains title and body database fields.

$ mix phx.gen.schema MyBlog.Article articles title:string body:text
* creating lib/blog/my_blog/article.ex
* creating priv/repo/migrations/20240311211707_create_articles.exs

Remember to update your repository by running migrations:

    $ mix ecto.migrate
Enter fullscreen mode Exit fullscreen mode

This command will create two new files:

  1. Schema file at lib/blog_my_blog/article.ex in the my_blog context.
  2. Migration file at priv/repo/migrations/<timestamp>_create_articles.exs.

6.2 Database Migrations

Migrations are used to alter the structure of an application's database. In Phoenix applications, migrations are written in Elixir so that they can be database-agnostic.

Let's take a look at the contents of our new migration file:

defmodule Blog.Repo.Migrations.CreateArticles do
  use Ecto.Migration

  def change do
    create table(:articles) do
      add :title, :string
      add :body, :text

      timestamps(type: :utc_datetime)
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

The create table(:articles) do block specifies how the new articles table should be constructed. By default, the table is automatically created with an auto-incrementing primary key id field.

Inside the block for create table(:articles), two columns are defined: title and body. These were added by the generator because we included them in our generate command.

On the last line of the block is timestamps(type: :utc_datetime). This method defines two additional columns named inserted_at and updated_at. Phoenix will manage these for us, setting the values when we create or update a schema.

Let's run our migration with the following command:

$ mix ecto.migrate
Enter fullscreen mode Exit fullscreen mode

The command will display output indicating that the table was created:

Compiling 2 files (.ex)
Generated blog app

17:19:38.008 [info] == Running 20240311211707 Blog.Repo.Migrations.CreateArticles.change/0 forward

17:19:38.010 [info] create table articles

17:19:38.102 [info] == Migrated 20240311211707 in 0.0s
Enter fullscreen mode Exit fullscreen mode

6.3 Using the Model to Interact with the Database

Let's launch the console with this command:

$ iex -S mix
Enter fullscreen mode Exit fullscreen mode

You should see an iex prompt like:

Erlang/OTP 26 [erts-14.2.2] [source] [64-bit] [smp:8:8] [ds:8:8:10] [async-threads:1] [dtrace]

Interactive Elixir (1.16.1) - press Ctrl+C to exit (type h() ENTER for help)
iex(1)>
Enter fullscreen mode Exit fullscreen mode

At this prompt, we can initialize a new Article object:

iex(1)> alias Blog.MyBlog.Article
Blog.MyBlog.Article
iex(2)> alias Blog.Repo
Blog.Repo
iex(3)> article = Repo.insert(%Article{title: "Hello Phoenix", body: "I am on Phoenix!"})
[debug] QUERY OK source="articles" db=1.6ms idle=1638.2ms
INSERT INTO "articles" ("title","body","inserted_at","updated_at") VALUES ($1,$2,$3,$4) RETURNING "id" ["Hello Phoenix", "I am on Phoenix!", ~U[2024-03-11 21:34:35Z], ~U[2024-03-11 21:34:35Z]]
โ†ณ :elixir.eval_external_handler/3, at: src/elixir.erl:405
{:ok,
 %Blog.MyBlog.Article{
   __meta__: #Ecto.Schema.Metadata<:loaded, "articles">,
   id: 1,
   title: "Hello Phoenix",
   body: "I am on Phoenix!",
   inserted_at: ~U[2024-03-11 21:34:35Z],
   updated_at: ~U[2024-03-11 21:34:35Z]
 }}
Enter fullscreen mode Exit fullscreen mode

The above output shows an INSERT INTO "articles" ... database query. This indicates that the article has been inserted into our table. And if we take a look at the article object again, we see something interesting has happened:

iex(4)> article
{:ok,
 %Blog.MyBlog.Article{
   __meta__: #Ecto.Schema.Metadata<:loaded, "articles">,
   id: 1,
   title: "Hello Phoenix",
   body: "I am on Phoenix!",
   inserted_at: ~U[2024-03-11 21:34:35Z],
   updated_at: ~U[2024-03-11 21:34:35Z]
 }}
Enter fullscreen mode Exit fullscreen mode

The id, created_at, and updated_at attributes of the object are now set. Phoenix did this for us when we saved the object.

When we want to fetch this article from the database, we can call find on the model and pass the id as an argument:

iex(5)> Repo.get!(Article, 1)
[debug] QUERY OK source="articles" db=1.3ms queue=1.3ms idle=533.9ms
SELECT a0."id", a0."title", a0."body", a0."inserted_at", a0."updated_at" FROM "articles" AS a0 WHERE (a0."id" = $1) [1]
โ†ณ :elixir.eval_external_handler/3, at: src/elixir.erl:405
%Blog.MyBlog.Article{
  __meta__: #Ecto.Schema.Metadata<:loaded, "articles">,
  id: 1,
  title: "Hello Phoenix",
  body: "I am on Phoenix!",
  inserted_at: ~U[2024-03-11 21:34:35Z],
  updated_at: ~U[2024-03-11 21:34:35Z]
}
Enter fullscreen mode Exit fullscreen mode

And when we want to fetch all articles from the database, we can call all using the repo:

iex(7)> Repo.all(Article)
[debug] QUERY OK source="articles" db=2.0ms queue=2.6ms idle=1101.3ms
SELECT a0."id", a0."title", a0."body", a0."inserted_at", a0."updated_at" FROM "articles" AS a0 []
โ†ณ :elixir.eval_external_handler/3, at: src/elixir.erl:405
[
    %Blog.MyBlog.Article{
        __meta__: #Ecto.Schema.Metadata<:loaded, "articles">,
        id: 1,
        title: "Hello Phoenix",
        body: "I am on Phoenix!",
        inserted_at: ~U[2024-03-11 21:34:35Z],
        updated_at: ~U[2024-03-11 21:34:35Z]
    }
]
Enter fullscreen mode Exit fullscreen mode

Exit the shell by doing Ctrl-C twice.

6.4 Showing a List of Articles

Phoenix has a notion of organizing code into a domain-driven-design (DDD) style structure with the use of Contexts. Contexts are used as an abstraction layer between schemas and the rest of the application, by encapsulating data access and data validation.

Let's create our MyBlog context at lib/blog/my_blog.ex:

defmodule Blog.MyBlog do
  import Ecto.Query, warn: false

  alias Blog.Repo
  alias Blog.MyBlog.Article

  def list_articles() do
    Repo.all(Article)
  end
end
Enter fullscreen mode Exit fullscreen mode

Here we're using alias in order to more easily reference different modules. We've created a list_articles function that takes no params, and will return all the articles in the database by using the Repo. We will use this list_articles function in the controller, rather than using Repo directly.

Let's update the index action of the ArticleController at lib/blog_web/article_controller.ex:

defmodule BlogWeb.ArticleController do
  use BlogWeb, :controller

  alias Blog.MyBlog

  def index(conn, _params) do
    articles = MyBlog.list_articles()
    render(conn, :index, articles: articles)
  end
end
Enter fullscreen mode Exit fullscreen mode

We are getting the articles in the database by using our MyBlog context's list_articles function, then assigning the articles in our HTML template the key articles. This will allow us to access the articles by using @articles in our template.

Let's update the HTML to use the passed in @articles in lib/blog_web/controllers/article_html/index.html.heex:

<h1 class="text-lg text-brand">
    Hello Phoenix
</h1>

<ul class="pt-5">
    <%= for article <- @articles do %>
    <li>
        <%= article.title %>
    </li>
    <% end %>
</ul>
Enter fullscreen mode Exit fullscreen mode

We are looping over all of the @articles with a for loop. For each article in @articles, we will display the article's title.

Navigate to http://localhost:4000 and see the article's we've created so far.

7 CRUDit Where CRUDit Is Due

Almost all web applications involve CRUD (Create, Read, Update, and Delete) operations. You may even find that the majority of the work your application does is CRUD. Phoenix acknowledges this, and provides many features to help simplify code doing CRUD.

Let's begin exploring these features by adding more functionality to our application.

7.1 Showing a Single Article

We currently have an index view that list all of our articles in our database. Let's add a new view that shows the title and body of a single article.

We start by adding a new route that maps to our new controller action (which we will add next). Open lib/blog/router.ex and insert the last route shown here:

scope "/", BlogWeb do
    pipe_through :browser

    get "/", ArticleController, :index
    get "/articles", ArticleController, :index
    get "/articles/:id", ArticleController, :show
end
Enter fullscreen mode Exit fullscreen mode

The new route is another get route, but it has something extra in its path: :id. This designates a route parameter. A route parameter captures a segment of the request's path, and puts that value in the params map. For example, when handling a request like GET http://localhost:4000/articles/1, 1 would be captured as the value for :id.

Let's first update our MyBlog context with a function that retrieves an Article based on its primary key id in lib/blog/my_blog/my_blog.ex:

defmodule Blog.MyBlog do
  import Ecto.Query

  alias Blog.Repo
  alias Blog.MyBlog.Article

  def list_articles() do
    Repo.all(Article)
  end

  def get_article!(id) do
    Repo.get!(Article, id)
  end
end
Enter fullscreen mode Exit fullscreen mode

Then let's add the show method which uses the new context method to the controller at lib/blog_web/controllers/article_controller.ex:

defmodule BlogWeb.ArticleController do
  use BlogWeb, :controller

  alias Blog.MyBlog

  def index(conn, _params) do
    articles = MyBlog.list_articles()
    render(conn, :index, articles: articles)
  end

  def show(conn, %{"id" => id}) do
    article = MyBlog.get_article!(id)
    render(conn, :show, article: article)
  end
end
Enter fullscreen mode Exit fullscreen mode

The show action method pulls the id from the incoming params, and passes the id to the MyBlog context's get_article!(id) function. The get_article! method in the context will return the article with the matching id. Lastly the show action assigns the article to the template variable named article, which will be accessible in the template using the @article variable.

Then let's create a new HTML template at lib/blog_web/controllers/article_html/show.html.heex at access the article using @article to display its title and body:

<h1>
    <%= @article.title %>
</h1>

<p><%= @article.body %></p>
Enter fullscreen mode Exit fullscreen mode

Now we can see the article when we visit http://localhost:4000/articles/1!

To finish up, let's add a convenient way to get to an article's page. We'll link each article's title in lib/blog_web/controllers/article_html/index.html.heex to its page:

<h1 class="text-lg text-brand">
    Hello Phoenix
</h1>

<ul class="pt-5">
    <%= for article <- @articles do %>
        <li>
            <a href={~p"/articles/#{article}"}>
                <%= article.title %>
            </a>
        </li>
    <% end %>
</ul>
Enter fullscreen mode Exit fullscreen mode

7.2 Resource Routing

So far, we've covered the "R" (Read) of CRUD. We will eventually cover the "C" (Create), "U" (Update), and "D" (Delete). As you might have guessed, we will do so by adding new routes, controller actions, and views. Whenever we have such a combination of routes, controller actions, and views that work together to perform CRUD operations on an entity, we call that entity a resource. For example, in our application, we would say an article is a resource.

Phoenix provides a routes method named resources that maps all of the conventional routes for a collection of resources, such as articles.
So before we proceed to the "C", "U", and "D" sections, let's replace the two get routes in lib/blog_web/router.ex with resources:

  scope "/", BlogWeb do
    pipe_through :browser

    get "/", ArticleController, :index
    resources "/articles", ArticleController
  end
Enter fullscreen mode Exit fullscreen mode

We can inspect what routes are mapped by running the mix phx.routes routes command:

  ...
  GET     /                                      BlogWeb.ArticleController :index
  GET     /articles                              BlogWeb.ArticleController :index
  GET     /articles/:id/edit                     BlogWeb.ArticleController :edit
  GET     /articles/new                          BlogWeb.ArticleController :new
  GET     /articles/:id                          BlogWeb.ArticleController :show
  POST    /articles                              BlogWeb.ArticleController :create
  PATCH   /articles/:id                          BlogWeb.ArticleController :update
  PUT     /articles/:id                          BlogWeb.ArticleController :update
  DELETE  /articles/:id                          BlogWeb.ArticleController :delete
  ...
Enter fullscreen mode Exit fullscreen mode

Nice!

7.3 Creating a New Article

Now we move on to the "C" (Create) of CRUD. Typically, in web applications, creating a new resource is a multi-step process. First, the user requests a form to fill out. Then, the user submits the form. If there are no errors, then the resource is created and some kind of confirmation is displayed. Else, the form is redisplayed with error messages, and the process is repeated.

In a Phoenix application, these steps are conventionally handled by a controller's new and create actions.

First let's add two new functions change_article and create_article to the context lib/blog/my_blog.ex:

defmodule Blog.MyBlog do
  import Ecto.Query, warn: false

  alias Blog.Repo
  alias Blog.MyBlog.Article

  def list_articles() do
    Repo.all(Article)
  end

  def get_article!(id) do
    Repo.get!(Article, id)
  end

  def change_article(%Article{} = article, attrs \\ %{}) do
    Article.changeset(article, attrs)
  end

  def create_article(attrs \\ %{}) do
    %Article{}
    |> Article.changeset(attrs)
    |> Repo.insert()
  end
end
Enter fullscreen mode Exit fullscreen mode

Then let's add a typical implementation of the new and create actions to lib/blog_web/article_controller.ex using the methods just added to the context, below the show action:

defmodule BlogWeb.ArticleController do
  use BlogWeb, :controller

  alias Blog.MyBlog
  alias Blog.MyBlog.Article

  def index(conn, _params) do
    articles = MyBlog.list_articles()
    render(conn, :index, articles: articles)
  end

  def show(conn, %{"id" => id}) do
    article = MyBlog.get_article!(id)
    render(conn, :show, article: article)
  end

  def new(conn, _params) do
    changeset = MyBlog.change_article(%Article{})
    render(conn, :new, changeset: changeset)
  end

  def create(conn, _params) do
    case MyBlog.create_article(%{title: "...", body: "..."}) do
      {:ok, article} ->
        conn
        |> put_flash(:info, "Article created successfully.")
        |> redirect(to: ~p"/articles/#{article}")

      {:error, %Ecto.Changeset{} = changeset} ->
        render(conn, :new, changeset: changeset)
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

The new create a changeset for an article, but does not save it.
By default, the new action will render lib/blog_web/controllers/article_web/new.html.heex, which we will create next.

The create action instantiates a new article with values for the title and body, and attempts to save it. If the article is saved successfully, the action redirects the browser to the article's page at "http://localhost:4000/articles/#{article.id}". Else, the action redisplays the form by rendering lib/blog_web/article_html/new.html.heex with status code 422 Unprocessable Entity. The title and body here are dummy values. After we create the form, we will come back and change these.

7.3.1 Using a Form Builder

We will use a feature of Phoenix called a form builder to create our form. Using a form builder, we can write a minimal amount of code to output a form that is fully configured and follows Phoenix conventions.

Let's create lib/blog_web/article_html/new.html.heex with the following contents:

<h1>
    New Article
</h1>

// replace with normal html?
<.simple_form :let={f} for={@changeset} action={~p"/articles"}>
    <.error :if={@changeset.action}>
        Oops, something went wrong! Please check the errors below.
    </.error>
    <.input field={f[:title]} type="text" label="Title" />
    <.input field={f[:body]} type="text" label="Body" />
    <:actions>
        <.button>Save Article</.button>
    </:actions>
</.simple_form>
Enter fullscreen mode Exit fullscreen mode

The simple_form helper...

The resulting output of our simple_form will look like:


<form action="/articles" method="post">
    <input name="_csrf_token" type="hidden" hidden="" value="...">
    <div class="mt-10 space-y-8 bg-white">
        <!-- @caller lib/blog_web/controllers/article_html/new.html.heex:9 () -->
        <!-- <BlogWeb.CoreComponents.input> lib/blog_web/components/core_components.ex:371 (blog) -->
        <div phx-feedback-for="article[title]">
            <!-- @caller lib/blog_web/components/core_components.ex:373 (blog) -->
            <!-- <BlogWeb.CoreComponents.label> lib/blog_web/components/core_components.ex:399 (blog) -->
            <label for="article_title" class="block text-sm font-semibold leading-6 text-zinc-800">
                Title
            </label><!-- </BlogWeb.CoreComponents.label> -->
            <input type="text" name="article[title]" id="article_title"
                   class="mt-2 block w-full rounded-lg text-zinc-900 focus:ring-0 sm:text-sm sm:leading-6 phx-no-feedback:border-zinc-300 phx-no-feedback:focus:border-zinc-400 border-zinc-300 focus:border-zinc-400">
        </div><!-- </BlogWeb.CoreComponents.input> -->
        <!-- @caller lib/blog_web/controllers/article_html/new.html.heex:10 () -->
        <!-- <BlogWeb.CoreComponents.input> lib/blog_web/components/core_components.ex:371 (blog) -->
        <div phx-feedback-for="article[body]">
            <!-- @caller lib/blog_web/components/core_components.ex:373 (blog) -->
            <!-- <BlogWeb.CoreComponents.label> lib/blog_web/components/core_components.ex:399 (blog) -->
            <label for="article_body" class="block text-sm font-semibold leading-6 text-zinc-800">
                Body
            </label><!-- </BlogWeb.CoreComponents.label> -->
            <input type="text" name="article[body]" id="article_body"
                   class="mt-2 block w-full rounded-lg text-zinc-900 focus:ring-0 sm:text-sm sm:leading-6 phx-no-feedback:border-zinc-300 phx-no-feedback:focus:border-zinc-400 border-zinc-300 focus:border-zinc-400">
        </div><!-- </BlogWeb.CoreComponents.input> -->
        <div class="mt-2 flex items-center justify-between gap-6">
            <!-- @caller lib/blog_web/controllers/article_html/new.html.heex:12 () -->
            <!-- <BlogWeb.CoreComponents.button> lib/blog_web/components/core_components.ex:230 (blog) -->
            <button class="phx-submit-loading:opacity-75 rounded-lg bg-zinc-900 hover:bg-zinc-700 py-2 px-3 text-sm font-semibold leading-6 text-white active:text-white/80 ">
                Save Article
            </button><!-- </BlogWeb.CoreComponents.button> -->
        </div>
    </div>
</form>
Enter fullscreen mode Exit fullscreen mode

7.3.2 Using Parameters

Submitted form data is put into the article_params map. We could pass or pluck these values individually to MyBlog.create_article, but that would be verbose and possibly error-prone. And it would become worse as we add more fields.

Instead, we will pass a single map that contains values from the form. In order to prevent anything malicious from happening if extra params are submitted, we will cast the fields we want in the article schema's changeset function.

Let's update the create action in the controller lib/blog_web/article_controller.ex to use the values in the article_params param:

defmodule BlogWeb.ArticleController do
  use BlogWeb, :controller

  alias Blog.MyBlog
  alias Blog.MyBlog.Article

  def index(conn, _params) do
    articles = MyBlog.list_articles()
    render(conn, :index, articles: articles)
  end

  def show(conn, %{"id" => id}) do
    article = MyBlog.get_article!(id)
    render(conn, :show, article: article)
  end

  def new(conn, _params) do
    changeset = MyBlog.change_article(%Article{})
    render(conn, :new, changeset: changeset)
  end

  def create(conn, %{"article" => article_params}) do
    case MyBlog.create_article(article_params) do
      {:ok, article} ->
        conn
        |> put_flash(:info, "Article created successfully.")
        |> redirect(to: ~p"/articles/#{article}")

      {:error, %Ecto.Changeset{} = changeset} ->
        render(conn, :new, changeset: changeset)
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

Try creating by visiting http://localhost:4000/articles/new now. After creating a new article, you should be redirected to that article's show page.

7.3.3 Validations and Displaying Error Messages

Try creating a new article without a title or body. You should see can't be blank error messages under the title input and body input. These validations for the article title and body field were created for us in the schema that was generated when we ran mix phx.gen.schema. Open lib/blog/my_blog/article.ex and notice the usage of validate_required in the changeset function:

defmodule Blog.MyBlog.Article do
  use Ecto.Schema
  import Ecto.Changeset

  schema "articles" do
    field :title, :string
    field :body, :string

    timestamps(type: :utc_datetime)
  end

  @doc false
  def changeset(article, attrs) do
    article
    |> cast(attrs, [:title, :body])
    |> validate_required([:title, :body])
  end
end
Enter fullscreen mode Exit fullscreen mode

Let's add an additional length check validation to the body field in lib/blog/my_blog/article.ex:

defmodule Blog.MyBlog.Article do
  use Ecto.Schema
  import Ecto.Changeset

  schema "articles" do
    field :title, :string
    field :body, :string

    timestamps(type: :utc_datetime)
  end

  @doc false
  def changeset(article, attrs) do
    article
    |> cast(attrs, [:title, :body])
    |> validate_required([:title, :body])
    |> validate_length(:body, min: 10)
  end
end
Enter fullscreen mode Exit fullscreen mode

Test the new validation by visiting http://localhost:4000/articles/new and try submitting the form with a body with less than 10 characters.

To understand how all of this works together, let's take another look at the new and create controller actions:

  def new(conn, _params) do
    changeset = MyBlog.change_article(%Article{})
    render(conn, :new, changeset: changeset)
  end

  def create(conn, %{"article" => article_params}) do
    case MyBlog.create_article(article_params) do
      {:ok, article} ->
        conn
        |> put_flash(:info, "Article created successfully.")
        |> redirect(to: ~p"/articles/#{article}")

      {:error, %Ecto.Changeset{} = changeset} ->
        render(conn, :new, changeset: changeset)
    end
  end
Enter fullscreen mode Exit fullscreen mode

When we visit http://localhost:4000/articles/new, the GET /articles/new request is mapped to the new action. The new action does not attempt to save article. Therefore, validations are not checked, and there will be no error messages.

When we submit the form, the POST /articles request is mapped to the create action. The create action does attempt to save article. Therefore, validations are checked. If any validation fails, article will not be saved, and lib/blog_web/article_html/new.html.heex will be rendered with error messages.

7.3.4 Finishing Up

We can now create an article by visiting http://localhost:4000/articles/new. To finish up, let's link to that page from the top of lib/blog_web/article_html/index.html.heex:

<h1 class="text-lg text-brand">
    Hello Phoenix
</h1>
<a href={~p"/articles/new"}>
    New Article
</a>

<ul class="pt-5">
    <%= for article <- @articles do %>
    <li>
        <a href={~p"/articles/#{article}"}>
            <%= article.title %>
        </a>
    </li>
    <% end %>
</ul>
Enter fullscreen mode Exit fullscreen mode

7.4 Updating an Article

We've covered the "CR" of CRUD. Now let's move on to the "U" (Update). Updating a resource is very similar to creating a resource. They are both multi-step processes. First, the user requests a form to edit the data. Then, the user submits the form. If there are no errors, then the resource is updated. Else, the form is redisplayed with error messages, and the process is repeated.

These steps are conventionally handled by a controller's edit and update actions. Let's add a typical implementation of these actions to lib/blog_web/article_controller.ex, below the create action::

defmodule BlogWeb.ArticleController do
  use BlogWeb, :controller

  alias Blog.MyBlog
  alias Blog.MyBlog.Article

  def index(conn, _params) do
    articles = MyBlog.list_articles()
    render(conn, :index, articles: articles)
  end

  def show(conn, %{"id" => id}) do
    article = MyBlog.get_article!(id)
    render(conn, :show, article: article)
  end

  def new(conn, _params) do
    changeset = MyBlog.change_article(%Article{})
    render(conn, :new, changeset: changeset)
  end

  def create(conn, %{"article" => article_params}) do
    case MyBlog.create_article(article_params) do
      {:ok, article} ->
        conn
        |> put_flash(:info, "Article created successfully.")
        |> redirect(to: ~p"/articles/#{article}")

      {:error, %Ecto.Changeset{} = changeset} ->
        render(conn, :new, changeset: changeset)
    end
  end

  def edit(conn, %{"id" => id}) do
    article = MyBlog.get_article!(id)
    changeset = MyBlog.change_article(article)
    render(conn, :edit, article: article, changeset: changeset)
  end

  def update(conn, %{"id" => id, "article" => article_params}) do
    article = MyBlog.get_article!(id)

    case MyBlog.update_article(article, article_params) do
      {:ok, article} ->
        conn
        |> put_flash(:info, "Article updated successfully.")
        |> redirect(to: ~p"/articles/#{article}")

      {:error, %Ecto.Changeset{} = changeset} ->
        render(conn, :edit, article: article, changeset: changeset)
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

Notice how the edit and update actions resemble the new and create actions. The edit action fetches the article from the database, and passes it to the view so that it can be used when building the form. The edit action fetches the article from the database, creates a changeset using that article, and passes the article and the changeset to the view. By using the argument :edit in the render function, the edit action will render lib/blog_web/controllers/article_html/edit.html.heex.

The update action (re-)fetches the article from the database, and attempts to update it with the submitted form data filtered by article_params. If no validations fail and the update is successful, the action redirects the browser to the article's page. Else, the action redisplays the form โ€” with error messages โ€” by rendering lib/blog_web/controllers/article_html/edit.html.heex.

The edit method uses the methods MyBlog.get_article! and MyBlog.change_article we already have in the MyBlog context. But the update action uses a method we still need to add: MyBlog.update_article. Add update_article to the MyBlog context.

defmodule Blog.MyBlog do
  import Ecto.Query, warn: false

  alias Blog.Repo
  alias Blog.MyBlog.Article

  def list_articles() do
    Repo.all(Article)
  end

  def get_article!(id) do
    Repo.get!(Article, id)
  end

  def change_article(%Article{} = article, attrs \\ %{}) do
    Article.changeset(article, attrs)
  end

  def create_article(attrs \\ %{}) do
    %Article{}
    |> Article.changeset(attrs)
    |> Repo.insert()
  end

  def update_article(%Article{} = article, attrs) do
    article
    |> Article.changeset(attrs)
    |> Repo.update()
  end
end
Enter fullscreen mode Exit fullscreen mode

7.4.1 Using Partials to Share View Code

Our edit form will look the same as our new form.

Because the code will be the same, we're going to factor it out into a shared view called a partial. Let's create lib/blog_web/controllers/article_html/article_form.html.heex with the following contents:

<.simple_form :let={f} for={@changeset} action={@action}>
  <.error :if={@changeset.action}>
    Oops, something went wrong! Please check the errors below.
  </.error>
  <.input field={f[:title]} type="text" label="Title" />
  <.input field={f[:body]} type="text" label="Body" />
  <:actions>
    <.button>Save Article</.button>
  </:actions>
</.simple_form>
Enter fullscreen mode Exit fullscreen mode

The above code is the same as our form in lib/blog_web/controllers/article_html/new.html.heex, except that action now uses @action.

Let's update lib/blog_web/controllers/article_html/new.html.heex to use the partial:

<.header>
    New Article
</.header>

<.article_form changeset={@changeset} action={~p"/articles"} />
Enter fullscreen mode Exit fullscreen mode

And now, let's create a very similar app/views/articles/edit.html.erb:

<.header>
    Edit Article
</.header>

<.article_form changeset={@changeset} action={~p"/articles/#{@article}"} />
Enter fullscreen mode Exit fullscreen mode

7.4.2 Finishing Up

We can now update an article by visiting its edit page, e.g. http://localhost:4000/articles/1/edit. To finish up, let's link to the edit page from the bottom of lib/blog_web/controllers/article_html/show.html.heex:

<h1 class="text-lg text-brand">
    <%= @article.title %>
</h1>

<p><%= @article.body %></p>

<ul>
    <li>
        <.link navigate={~p"/articles/#{@article}/edit"}>Edit</.link>
    </li>
</ul>
Enter fullscreen mode Exit fullscreen mode

7.5 Deleting an Article

Finally, we arrive at the "D" (Delete) of CRUD. Deleting a resource is a simpler process than creating or updating. It only requires a route and a controller action. And our resourceful routing (resources :articles) already provides the route, which maps DELETE /articles/:id requests to the destroy action of ArticleController.

defmodule BlogWeb.ArticleController do
  use BlogWeb, :controller

  alias Blog.MyBlog
  alias Blog.MyBlog.Article

  def index(conn, _params) do
    articles = MyBlog.list_articles()
    render(conn, :index, articles: articles)
  end

  def show(conn, %{"id" => id}) do
    article = MyBlog.get_article!(id)
    render(conn, :show, article: article)
  end

  def new(conn, _params) do
    changeset = MyBlog.change_article(%Article{})
    render(conn, :new, changeset: changeset)
  end

  def create(conn, %{"article" => article_params}) do
    case MyBlog.create_article(article_params) do
      {:ok, article} ->
        conn
        |> put_flash(:info, "Article created successfully.")
        |> redirect(to: ~p"/articles/#{article}")

      {:error, %Ecto.Changeset{} = changeset} ->
        render(conn, :new, changeset: changeset)
    end
  end

  def edit(conn, %{"id" => id}) do
    article = MyBlog.get_article!(id)
    changeset = MyBlog.change_article(article)
    render(conn, :edit, article: article, changeset: changeset)
  end

  def update(conn, %{"id" => id, "article" => article_params}) do
    article = MyBlog.get_article!(id)

    case MyBlog.update_article(article, article_params) do
      {:ok, article} ->
        conn
        |> put_flash(:info, "Article updated successfully.")
        |> redirect(to: ~p"/articles/#{article}")

      {:error, %Ecto.Changeset{} = changeset} ->
        render(conn, :edit, article: article, changeset: changeset)
    end
  end

  def delete(conn, %{"id" => id}) do
    article = MyBlog.get_article!(id)
    {:ok, _article} = MyBlog.delete_article(article)

    conn
    |> put_flash(:info, "Article deleted successfully.")
    |> redirect(to: ~p"/articles")
  end
end
Enter fullscreen mode Exit fullscreen mode

The newly added delete method in the controller uses a new method in the MyBlog context: MyBlog.delete_article. Let's add that now:

defmodule Blog.MyBlog do
  import Ecto.Query, warn: false

  alias Blog.Repo
  alias Blog.MyBlog.Article

  def list_articles() do
    Repo.all(Article)
  end

  def get_article!(id) do
    Repo.get!(Article, id)
  end

  def change_article(%Article{} = article, attrs \\ %{}) do
    Article.changeset(article, attrs)
  end

  def create_article(attrs \\ %{}) do
    %Article{}
    |> Article.changeset(attrs)
    |> Repo.insert()
  end

  def update_article(%Article{} = article, attrs) do
    article
    |> Article.changeset(attrs)
    |> Repo.update()
  end

  def delete_article(%Article{} = article) do
    Repo.delete(article)
  end
end
Enter fullscreen mode Exit fullscreen mode

The destroy action fetches the article from the database, and calls destroy on it. Then, it redirects the browser to the root path with status code 303 See Other.

We have chosen to redirect to the root path because that is our main access point for articles. But, in other circumstances, you might choose to redirect to e.g. |> redirect(to: ~p"/articles").

Now let's add a link at the bottom of lib/blog_web/controllers/article_html/show.html.heex so that we can delete an article from its own page:

<h1 class="text-lg text-brand">
    <%= @article.title %>
</h1>

<p><%= @article.body %></p>

<ul>
    <li>
        <.link navigate={~p"/articles/#{@article}/edit"}>Edit</.link>
    </li>
    <li>
        <.link href={~p"/articles/#{@article}"} method="delete" data-confirm="Are you sure?">
          Delete
        </.link>
    </li>
</ul>
Enter fullscreen mode Exit fullscreen mode

And that's it! We can now list, show, create, update, and delete articles! InCRUDable!

8 Adding a Second Model

It's time to add a second model to the application. The second model will handle comments on articles.

8.1 Generating a Model

We're going to see the same generator that we used before when creating the Article model. This time we'll create a Comment model to hold a reference to an article. Run this command in your terminal:

$ mix phx.gen.context MyBlog Comment comments commenter:string body:text article_id:references:articles
Enter fullscreen mode Exit fullscreen mode

It will ask you if you want to add functions to the existing context:

You are generating into an existing context.

The Blog.MyBlog context currently has 6 functions and 1 file in its directory.

  * It's OK to have multiple resources in the same context as long as they are closely related. But if a context grows too large, consider breaking it apart

  * If they are not closely related, another context probably works better

The fact two entities are related in the database does not mean they belong to the same context.

If you are not sure, prefer creating a new context over adding to the existing one.

Would you like to proceed? [Yn] 
Enter fullscreen mode Exit fullscreen mode

We want to put the new Comments model in the same context as the existing Article model. Press enter.

It will create new files and add to existing files:

* creating lib/blog/my_blog/comment.ex
* creating priv/repo/migrations/20240423232742_create_comments.exs
* injecting lib/blog/my_blog.ex
* creating test/blog/my_blog_test.exs
* injecting test/blog/my_blog_test.exs
* creating test/support/fixtures/my_blog_fixtures.ex
* injecting test/support/fixtures/my_blog_fixtures.ex

Remember to update your repository by running migrations:

    $ mix ecto.migrate
Enter fullscreen mode Exit fullscreen mode

โš ๏ธ See what files are generated for each of the mix phx.gen commands in the docs here.

First, take a look at the Comment model, located at lib/blog/my_blog/comment.ex:

defmodule Blog.MyBlog.Comment do
  use Ecto.Schema
  import Ecto.Changeset

  schema "comments" do
    field :body, :string
    field :commenter, :string
    field :article_id, :id

    timestamps(type: :utc_datetime)
  end

  @doc false
  def changeset(comment, attrs) do
    comment
    |> cast(attrs, [:commenter, :body])
    |> validate_required([:commenter, :body])
  end
end
Enter fullscreen mode Exit fullscreen mode

In addition to the model, Pheonix has also made a migration to create the corresponding database table:

defmodule Blog.Repo.Migrations.CreateComments do
  use Ecto.Migration

  def change do
    create table(:comments) do
      add :commenter, :string
      add :body, :text
      add :article_id, references(:articles, on_delete: :nothing)

      timestamps(type: :utc_datetime)
    end

    create index(:comments, [:article_id])
  end
end
Enter fullscreen mode Exit fullscreen mode

The article_id field is used to reference the id field on the articles table.

Let's make one small change to the on_delete option for the article_id field to keep data consistent.

defmodule Blog.Repo.Migrations.CreateComments do
  use Ecto.Migration

  def change do
    create table(:comments) do
      add :commenter, :string
      add :body, :text
      add :article_id, references(:articles, on_delete: :delete_all)

      timestamps(type: :utc_datetime)
    end

    create index(:comments, [:article_id])
  end
end
Enter fullscreen mode Exit fullscreen mode

This will help keep out database clean, so when an article gets deleted, the associated comments for that article also gets deleted. The delete_all option will prevent comments from existing in the database without an article existing.

Go ahead and run the migration:

mix ecto.migrate
Enter fullscreen mode Exit fullscreen mode

Phoenix is smart enough to only execute the migrations that have not already been run against the current database, so in this case you will just see:

Generated blog app

19:41:18.734 [info] == Running 20240329022229 Blog.Repo.Migrations.CreateArticles.change/0 forward

19:41:18.738 [info] create table articles

19:41:18.738 [info] == Migrated 20240329022229 in 0.0s

19:41:18.747 [info] == Running 20240423232324 Blog.Repo.Migrations.CreateComments.change/0 forward

19:41:18.747 [info] create table comments

19:41:18.747 [info] create index comments_article_id_index

19:41:18.747 [info] == Migrated 20240423232324 in 0.0s
Enter fullscreen mode Exit fullscreen mode

8.2 Associating Models

Ecto associations let you easily declare the relationship between two models. In the case of comments and articles, you could write out the relationships this way:

  • Each comment belongs to one article.
  • One article can have many comments.

In fact, this is very close to the syntax that Ecto uses to declare this association. Let's modify the Comment model to make each comment belong_to an Article:

Update the Comment model located at lib/blog/my_blog/comment.ex with this:

defmodule Blog.MyBlog.Comment do
  use Ecto.Schema
  import Ecto.Changeset
  alias Blog.MyBlog.Article

  schema "comments" do
    field :body, :string
    field :commenter, :string
    belongs_to :article, Article

    timestamps(type: :utc_datetime)
  end

  @doc false
  def changeset(comment, attrs) do
    comment
    |> cast(attrs, [:commenter, :body, :article_id])
    |> validate_required([:commenter, :body])
    |> assoc_constraint(:article)
  end
end
Enter fullscreen mode Exit fullscreen mode

You'll need to edit lib/blog/my_blog/article.ex to add the other side of the association:

defmodule Blog.MyBlog.Article do
  use Ecto.Schema
  import Ecto.Changeset
  alias Blog.MyBlog.Comment

  schema "articles" do
    field :title, :string
    field :body, :string
    has_many :comments, Comment

    timestamps(type: :utc_datetime)
  end

  @doc false
  def changeset(article, attrs) do
    article
    |> cast(attrs, [:title, :body])
    |> validate_required([:title, :body])
    |> validate_length(:body, min: 10)
  end
end
Enter fullscreen mode Exit fullscreen mode

โš ๏ธ For more information on Ecto associations, see the Ecto Assocations guide.

Let's test the relationship in iex:

$ iex -S mix
[info] Migrations already up
Interactive Elixir (1.14.3) - press Ctrl+C to exit (type h() ENTER for help)
iex(1)> 

Enter fullscreen mode Exit fullscreen mode

First create a new article:

iex(1)> alias Blog.MyBlog.Article
Blog.MyBlog.Article
iex(2)> article = %Article{title: "My test article", body: "Has many comments"}
%Blog.MyBlog.Article{
  __meta__: #Ecto.Schema.Metadata<:built, "articles">,
  id: nil,
  body: "Has many comments",
  title: "My test article",
  inserted_at: nil,
  updated_at: nil
}
iex(3)> alias Blog.Repo
Blog.Repo
iex(4)> article = Repo.insert!(article)
[debug] QUERY OK source="articles" db=9.6ms idle=1008.4ms
INSERT INTO "articles" ("body","title","inserted_at","updated_at") VALUES (?,?,?,?) RETURNING "id" ["Has many comments", "My test article", ~U[2024-04-23 23:53:05Z], ~U[2024-04-23 23:53:05Z]]
โ†ณ anonymous fn/4 in :elixir.eval_external_handler/1, at: src/elixir.erl:309
%Blog.MyBlog.Article{
  __meta__: #Ecto.Schema.Metadata<:loaded, "articles">,
  id: 4,
  title: "My test article",
  body: "Has many comments",
  comments: #Ecto.Association.NotLoaded<association :comments is not loaded>,
  inserted_at: ~U[2024-04-24 00:09:21Z],
  updated_at: ~U[2024-04-24 00:09:21Z]
}
Enter fullscreen mode Exit fullscreen mode

Then let's create a comment for the article we just created:

iex(11)> comment = Ecto.build_assoc(article, :comments, %{commenter: "First commenter", body: "Sweet article"})
%Blog.MyBlog.Comment{
  __meta__: #Ecto.Schema.Metadata<:built, "comments">,
  id: nil,
  body: "Sweet article",
  commenter: "First commenter",
  article_id: 5,
  article: #Ecto.Association.NotLoaded<association :article is not loaded>,
  inserted_at: nil,
  updated_at: nil
}
iex(12)> Repo.insert!(comment)
[debug] QUERY OK source="comments" db=0.4ms idle=1273.9ms
INSERT INTO "comments" ("article_id","body","commenter","inserted_at","updated_at") VALUES (?,?,?,?,?) RETURNING "id" [5, "Sweet article", "First commenter", ~U[2024-04-24 00:12:35Z], ~U[2024-04-24 00:12:35Z]]
โ†ณ anonymous fn/4 in :elixir.eval_external_handler/1, at: src/elixir.erl:309
%Blog.MyBlog.Comment{
  __meta__: #Ecto.Schema.Metadata<:loaded, "comments">,
  id: 1,
  body: "Sweet article",
  commenter: "First commenter",
  article_id: 5,
  article: #Ecto.Association.NotLoaded<association :article is not loaded>,
  inserted_at: ~U[2024-04-24 00:12:35Z],
  updated_at: ~U[2024-04-24 00:12:35Z]
}
Enter fullscreen mode Exit fullscreen mode

Let's see if it worked:

iex(14)> Repo.get(Article, article.id) |> Repo.preload(:comments)
[debug] QUERY OK source="articles" db=0.0ms queue=0.2ms idle=1774.5ms
SELECT a0."id", a0."title", a0."body", a0."inserted_at", a0."updated_at" FROM "articles" AS a0 WHERE (a0."id" = ?) [5]
โ†ณ anonymous fn/4 in :elixir.eval_external_handler/1, at: src/elixir.erl:309
[debug] QUERY OK source="comments" db=0.0ms queue=0.1ms idle=1781.4ms
SELECT c0."id", c0."body", c0."commenter", c0."article_id", c0."inserted_at", c0."updated_at", c0."article_id" FROM "comments" AS c0 WHERE (c0."article_id" = ?) ORDER BY c0."article_id" [5]
โ†ณ anonymous fn/4 in :elixir.eval_external_handler/1, at: src/elixir.erl:309
%Blog.MyBlog.Article{
  __meta__: #Ecto.Schema.Metadata<:loaded, "articles">,
  id: 5,
  title: "My test article",
  body: "Has many comments",
  comments: [
    %Blog.MyBlog.Comment{
      __meta__: #Ecto.Schema.Metadata<:loaded, "comments">,
      id: 1,
      body: "Sweet article",
      commenter: "First commenter",
      article_id: 5,
      article: #Ecto.Association.NotLoaded<association :article is not loaded>,
      inserted_at: ~U[2024-04-24 00:12:35Z],
      updated_at: ~U[2024-04-24 00:12:35Z]
    }
  ],
  inserted_at: ~U[2024-04-24 00:12:10Z],
  updated_at: ~U[2024-04-24 00:12:10Z]
}
Enter fullscreen mode Exit fullscreen mode

In the example above, Ecto.build_assoc received an existing article struct, that was already persisted to the database, and built a Comment struct, based on its :comments association, with the article_id foreign key field properly set to the ID in the article struct.

8.3 Adding a Route for Comments

As with the articles controller, we will need to add a route so that Phoenix knows where we would like to navigate to see comments. Open up the lib/blog/router.ex file again, and edit it as follows:

  scope "/", BlogWeb do
    pipe_through :browser

    get "/", ArticleController, :index
    resources "/articles", ArticleController do
        resources "/comments", CommentController
    end
  end
Enter fullscreen mode Exit fullscreen mode

This creates comments as a nested resource within articles. This is another part of capturing the hierarchical relationship that exists between articles and comments.

8.4 Generating a Controller

With the model in hand, you can turn your attention to creating a matching controller. Again, we'll create it by hand by creating a new, empty file at lib/blog_web/controllers/comment_controller.ex.

Let's first wire up the Article show template (lib/blog_web/controllers/article_html/show.html.heex) to let us create a new comment:

<h1 class="text-lg text-brand">
    <%= @article.title %>
</h1>

<p><%= @article.body %></p>

<ul>
    <li>
        <.link navigate={~p"/articles/#{@article}/edit"}>Edit</.link>
    </li>
    <li>
        <.link href={~p"/articles/#{@article}"} method="delete" data-confirm="Are you sure?">
          Delete
        </.link>
    </li>
</ul>

<p>
<.simple_form :let={f} for={@comment_changeset} action={~p"/articles/#{@article}/comments"}>
  <.error :if={@comment_changeset.action}>
    Oops, something went wrong! Please check the errors below.
  </.error>
  <.input field={f[:commenter]} type="text" label="Commenter" />
  <.input field={f[:body]} type="text" label="Body" />
  <:actions>
    <.button>Create Comment</.button>
  </:actions>
</.simple_form>
</p>
Enter fullscreen mode Exit fullscreen mode

This adds a form on the Article show page that creates a new comment by calling the CommentController create action.

Let's wire up the create in lib/blog/blog_web/controllers/comment_controller.ex:

defmodule BlogWeb.CommentController do
  use BlogWeb, :controller

  alias Blog.MyBlog
  alias Blog.MyBlog.Comment

  def create(conn, %{"comment" => comment_params, "article_id" => article_id} = params) do

    case MyBlog.create_comment(Map.merge(comment_params, %{"article_id" => article_id})) do
      {:ok, comment} ->
        conn
        |> put_flash(:info, "Comment created successfully.")
        |> redirect(to: ~p"/articles/#{comment.article_id}")

      {:error, %Ecto.Changeset{} = changeset} ->
        render(conn, :new, changeset: changeset)
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

You'll see a bit more complexity here than you did in the controller for articles. That's a side-effect of the nesting that you've set up. Each request for a comment has to keep track of the article to which the comment is attached, thus the article_id must be merged to the comment_params map to make the association betwen the comment and the article.

Once we have made the new comment, we send the user back to the original article using the redirect(to: ~p"/articles/#{comment.article_id}") helper. As we have already seen, this calls the show action of the ArticleController which in turn renders the show.html.heex template. This is where we want the comment to show, so let's add that to the lib/blog_web/controllers/article_html/show.html.heex:

<h1 class="text-lg text-brand">
    <%= @article.title %>
</h1>

<p><%= @article.body %></p>

<ul class="py-2">
    <li>
        <.link navigate={~p"/articles/#{@article}/edit"}>Edit</.link>
    </li>
    <li>
        <.link href={~p"/articles/#{@article}"} method="delete" data-confirm="Are you sure?">
          Delete
        </.link>
    </li>
</ul>

<h2 class="text-md text-brand">
    Comments
</h2>
<%= for comment <- @article.comments do %>
<div class="py-2">
    <p>
        <strong>Commenter:</strong>
        <%= comment.commenter %>
    </p>
    <p>
        <strong>Comment:</strong>
        <%= comment.body %>
    </p>
</div>
<% end %>

<p>
<.simple_form :let={f} for={@comment_changeset} action={~p"/articles/#{@article}/comments"}>
  <.error :if={@comment_changeset.action}>
    Oops, something went wrong! Please check the errors below.
  </.error>
  <.input field={f[:commenter]} type="text" label="Commenter" />
  <.input field={f[:body]} type="text" label="Body" />
  <:actions>
    <.button>Create Comment</.button>
  </:actions>
</.simple_form>
</p>
Enter fullscreen mode Exit fullscreen mode

Lastly to do is to preload the comments for the @article. Unlike other ORMs, Ecto does not allow lazy loading, meaning that all requests to the database must be explicit. Add Repo.preload(:comments) to the get_article! method in the MyBlog context in lib/blog/my_blog/my_blog.ex:

defmodule Blog.MyBlog do
  import Ecto.Query, warn: false

  alias Blog.Repo
  alias Blog.MyBlog.Article

  def list_articles() do
    Repo.all(Article)
  end

  def get_article!(id) do
    Repo.get!(Article, id)
    |> Repo.preload(:comments)
  end

  def change_article(%Article{} = article, attrs \\ %{}) do
    Article.changeset(article, attrs)
  end

  def create_article(attrs \\ %{}) do
    %Article{}
    |> Article.changeset(attrs)
    |> Repo.insert()
  end

  def update_article(%Article{} = article, attrs) do
    article
    |> Article.changeset(attrs)
    |> Repo.update()
  end

  def delete_article(%Article{} = article) do
    Repo.delete(article)
  end

  alias Blog.MyBlog.Comment

  @doc """
  Returns the list of comments.

  ## Examples

      iex> list_comments()
      [%Comment{}, ...]

  """
  def list_comments do
    Repo.all(Comment)
  end

  @doc """
  Gets a single comment.

  Raises `Ecto.NoResultsError` if the Comment does not exist.

  ## Examples

      iex> get_comment!(123)
      %Comment{}

      iex> get_comment!(456)
      ** (Ecto.NoResultsError)

  """
  def get_comment!(id), do: Repo.get!(Comment, id)

  @doc """
  Creates a comment.

  ## Examples

      iex> create_comment(%{field: value})
      {:ok, %Comment{}}

      iex> create_comment(%{field: bad_value})
      {:error, %Ecto.Changeset{}}

  """
  def create_comment(attrs \\ %{}) do
    %Comment{}
    |> Comment.changeset(attrs)
    |> Repo.insert()
  end

  @doc """
  Updates a comment.

  ## Examples

      iex> update_comment(comment, %{field: new_value})
      {:ok, %Comment{}}

      iex> update_comment(comment, %{field: bad_value})
      {:error, %Ecto.Changeset{}}

  """
  def update_comment(%Comment{} = comment, attrs) do
    comment
    |> Comment.changeset(attrs)
    |> Repo.update()
  end

  @doc """
  Deletes a comment.

  ## Examples

      iex> delete_comment(comment)
      {:ok, %Comment{}}

      iex> delete_comment(comment)
      {:error, %Ecto.Changeset{}}

  """
  def delete_comment(%Comment{} = comment) do
    Repo.delete(comment)
  end

  @doc """
  Returns an `%Ecto.Changeset{}` for tracking comment changes.

  ## Examples

      iex> change_comment(comment)
      %Ecto.Changeset{data: %Comment{}}

  """
  def change_comment(%Comment{} = comment, attrs \\ %{}) do
    Comment.changeset(comment, attrs)
  end
end
Enter fullscreen mode Exit fullscreen mode

Now try creating a new comment. You should be redirected back to the article show page, and see the newly created comment.

9 Refactoring

Now that we have articles and comments working, take a look at the lib/blog_web/controllers/article_html/show.html.heex template. It is getting long and awkward. We can use partials to clean it up.

9.1 Rendering Partial Collections

First, we will make a comment partial to extract showing all the comments for the article. Create the file lib/blog_web/controllers/article_html/_comment.html.heex and put the following into it:

<div class="py-2">
    <p>
        <strong>Commenter:</strong>
        <%= @comment.commenter %>
    </p>
    <p>
        <strong>Comment:</strong>
        <%= @comment.body %>
    </p>
</div>
Enter fullscreen mode Exit fullscreen mode

Then you can change lib/blog_web/controllers/article_html/show.html.heex to look like the following:

<h1 class="text-lg text-brand">
    <%= @article.title %>
</h1>

<p><%= @article.body %></p>

<ul class="py-2">
    <li>
        <.link navigate={~p"/articles/#{@article}/edit"}>Edit</.link>
    </li>
    <li>
        <.link href={~p"/articles/#{@article}"} method="delete" data-confirm="Are you sure?">
          Delete
        </.link>
    </li>
</ul>

<h2 class="text-md text-brand">
    Comments
</h2>
<%= for comment <- @article.comments do %>
    <._comment comment={comment} />
<% end %>

<p>
<.simple_form :let={f} for={@comment_changeset} action={~p"/articles/#{@article}/comments"}>
  <.error :if={@comment_changeset.action}>
    Oops, something went wrong! Please check the errors below.
  </.error>
  <.input field={f[:commenter]} type="text" label="Commenter" />
  <.input field={f[:body]} type="text" label="Body" />
  <:actions>
    <.button>Create Comment</.button>
  </:actions>
</.simple_form>
</p>
Enter fullscreen mode Exit fullscreen mode

This will now render the partial in lib/blog_web/controllers/article_html/_comment.html.heex once for each comment that is in the @article.comments collection.

9.2 Rendering a Partial Form

Let us also move that new comment section out to its own partial. Again, you create a file lib/blog_web/controllers/comment_html/_form.html.heex containing:

<.simple_form :let={f} for={@comment_changeset} action={~p"/articles/#{@article}/comments"}>
<.error :if={@comment_changeset.action}>
Oops, something went wrong! Please check the errors below.
</.error>
<.input field={f[:commenter]} type="text" label="Commenter" />
<.input field={f[:body]} type="text" label="Body" />
<:actions>
    <.button>Create Comment</.button>
</:actions>
</.simple_form>
Enter fullscreen mode Exit fullscreen mode

Then you make the lib/blog_web/controllers/article_html/show.html.heex look like the following:

<h1 class="text-lg text-brand">
    <%= @article.title %>
</h1>

<p><%= @article.body %></p>

<ul class="py-2">
    <li>
        <.link navigate={~p"/articles/#{@article}/edit"}>Edit</.link>
    </li>
    <li>
        <.link href={~p"/articles/#{@article}"} method="delete" data-confirm="Are you sure?">
          Delete
        </.link>
    </li>
</ul>

<h2 class="text-md text-brand">
    Comments
</h2>
<%= for comment <- @article.comments do %>
    <._comment comment={comment} />
<% end %>

<p>
    <._form comment_changeset={@comment_changeset} article={@article} />
</p>
Enter fullscreen mode Exit fullscreen mode

Lastly, we need to update lib/blog_web/article_html.ex to include the form template in the new comment directory:

defmodule BlogWeb.ArticleHTML do
  use BlogWeb, :html

  embed_templates "article_html/*"
  embed_templates "comment_html/*"
end
Enter fullscreen mode Exit fullscreen mode

Refresh and the form should still work.

9.3 Sharing code between schemas

Not sure of what the best way to implement this in Phoenix is actually.

10 Deleting Comments

Another important feature of a blog is being able to delete spam comments. To do this, we need to implement a link of some sort in the view and a destroy action in the CommentController.

So first, let's add the delete link in the lib/blog_web/controllers/comment_html/_comment.html.heex partial:

<div class="py-2">
    <p>
        <strong>Commenter:</strong>
        <%= @comment.commenter %>
    </p>
    <p>
        <strong>Comment:</strong>
        <%= @comment.body %>
    </p>
</div>

<.link href={~p"/articles/#{@comment.article_id}/comments/#{@comment.id}"} method="delete" data-confirm="Are you sure?">
Delete Comment
</.link>
Enter fullscreen mode Exit fullscreen mode

Clicking this new "Delete Comment" link will fire off a DELETE /articles/:article_id/comments/:id to our CommentController, which can then use this to find the comment we want to delete, so let's add a delete action to our controller (lib/blog_web/controllers/comment_controller.ex):

defmodule BlogWeb.CommentController do
  use BlogWeb, :controller

  alias Blog.MyBlog
  alias Blog.MyBlog.Comment

  def create(conn, %{"comment" => comment_params, "article_id" => article_id} = params) do
    case MyBlog.create_comment(Map.merge(comment_params, %{"article_id" => article_id})) do
      {:ok, comment} ->
        conn
        |> put_flash(:info, "Comment created successfully.")
        |> redirect(to: ~p"/articles/#{comment.article_id}")

      {:error, %Ecto.Changeset{} = changeset} ->
        render(conn, :new, changeset: changeset)
    end
  end

  def delete(conn, %{"article_id" => article_id, "id" => id}) do
    comment = MyBlog.get_comment_for_article!(article_id, id)
    {:ok, comment} = MyBlog.delete_comment(comment)

    conn
    |> put_flash(:info, "Comment deleted successfully.")
    |> redirect(to: ~p"/articles/#{comment.article_id")
  end
end
Enter fullscreen mode Exit fullscreen mode

The destroy action will find the comment we are looking at in the article's comments, and then remove it from the database and send us back to the show action for the article.

Let's add the new context method MyBlog.get_comment_for_article! to the MyBlog context at lib/blog/my_blog/my_blog.ex:

def get_comment_for_article!(article_id, comment_id) do
  Repo.one(from c in Comment, where: c.article_id == ^article_id and c.id == ^comment_id)
end
Enter fullscreen mode Exit fullscreen mode

Try deleting a comment now.

Top comments (0)