Ruby-on-rails to Phoenix: Porting the blogs tutorial
Preface
There are too many social media comments on how Ruby-on-Rails is best suited for rapid prototyping. Similarly there are high praises to Elixir-Phoenix framework, especially from folks who have worked on both. But there is no easy Getting Started tutorial for Phoenix. In fact Phoenix has quite a high entry barrier before things start making sense. This is easier if someone is coming from Rails world, but for someone starting Phoenix directly from Python-Flask, things look quite complex. I believe beginner documentation is one of the important things holding Phoenix back compared to Ruby on Rails.
So, this is my attempt to recreate Ruby-on-Rails Blog tutorial in Phoenix, with roughly same set of steps.
Here is link to Ruby tutorial I am following and porting.
Installation and setup (Step 1 - 3)
Installation and setup for both Ruby on Rails stack and Elixir-Phoenix stack is equally easy or complex, depending on OS version. For me, using Ubuntu 22.04 in 2024, I can’t use system packages for Ruby on Rails. So I used rbenv to install Ruby 3.2 and then installed rails 8.0 following tutorial instructions.
For Elixir, I used system provided version 1.12.2. For latest Phoenix (especially with LiveView 1.0) I’ll need to use version manager like asdf. But, I’ll stick to using Phoenix version 1.6.6 for this tutorial.
3.2 Creating Blog Application:
For creating new Phoenix project, we have to use mix which is generic Elixir generator.
mix phx.new blogs --database sqlite3
this command will scaffold entire project and set up DB connection. Only difference compared to Rails command is explicit --database sqlite3 specification. By default Ecto which is database handler for Elixir and Phoenix uses PostgreSQL.
4.1 Starting up the server:
mix phx.server
this command starts a development server at Port 4000. I was already running another Phoenix app on this port, so I had to change config/dev.exs -> config :blogs, BlogsWeb.Endpoint -> http: [ip: {127, 0, 0, 1}, port: 4000]. I changed it to 4001 and server started without any issue.
4.2 Adding a controller:
Create a file article_controller.ex in lib/blogs_web/controllers/ folder and add following content:
defmodule BlogsWeb.ArticleController do
use BlogsWeb, :controller
def index(conn, _params) do
render(conn, "index.html")
end
end
index function of controller renders index.html.heex file as index.html. Add index.html.heex file in lib/blogs_web/templates/article folder (create folder if necessary) and add following content:
<h2>Hello World</h2>
Finally, edit lib/blogs_web/router.ex. In Scope "/", BlogsWeb scope, we need to add route line: get "/articles", ArticleController, :index
For setting root page, I just added get "/", ArticleController, :index line in same scope.
This should render our hello world page at /articles.
Difference with Rails:
This is first step where we can see some difference in approaches. For Rails, I don’t know what all configuration changes are needed to add a controller manually. But it is easy to just run generator command.
For Phoenix mix phx.gen.html generates all views for CRUD operation. But adding a controller is just adding a file in controllers folder.
Another difference is explicit call to “index.html” in render function. This will render embedded html file index.html.heex. Here Phoenix prefers configuration over convention.
One convention part in Phoenix is Article controller’s views are listed in templates/article folder.
6.1 Generating model:
For model generation, mix generator is useful since it creates model struct file and migration file. To generate model, run this command: mix phx.gen.schema Article articles title:string body:string. This will create article.ex file in lib/blogs with schema definition and changeset function. It will also create migration in prov/repo/migrations folder.
6.2 Migrating model:
To apply migrations and create table, run
mix ecto.migrate
So far this is pretty much same what we do in Rails.
6.3 Interacting with the Database using console:
This is the part where Elixir shines over Ruby. Since Elixir is a functional language, all parts of Phoenix syntax can be evaluated independently by calling underlying function.
E.g. in Phoenix/Ecto, a model is represented as a struct. Repo module takes care of interacting with actual database. So, to create a new DB record and insert into DB, we need to follow these steps:
- Start console by running
iex -S mixcommand. From this console, we can call functions within project directly. - Create struct instance with data:
record = %Blogs.Article{title: "abcd", body: "zxcv"} - Insert record into DB:
Blogs.Repo.insert(record)
Get an Article by id
Blogs.Repo.get(Blogs.Article, id)
Get all articles
Blogs.Repo.all(Blogs.Article)
6.4 Show list of articles in index page
To show list of articles in a page, we need to change 2 things:
- In controller, get list of articles and pass it as data to embedded html.heex page
- In embedded html, read this list and create a dom element (
liin this case) for each item in the list
For passing list of articles to embedded view, change index function to following code:
def index(conn, _params) do
render(conn, "index.html", articles: Repo.all(Article))
end
Here we are passing the list of all articles as an assigns map. This map is accessible to embedded view page.
In heex page, we can access this list and render a list using following code:
<ul>
<%= for entry <- @articles do %>
<li>
<p><%= entry.title %></p>
<p> <a> Update </a> </p>
<p> <a> Delete </a> </p>
</li>
<% end %>
</ul>
@articles in above code refers to articles passed to render function. Using HEEX DSL syntax for looping <%= for entry <- @articles do %> we can loop over each article as value of entry.
We’ll update link to update and delete operations later.
Comparison with Rails:
This part is also pretty similar to Rails counterpart. Only noticable difference is that Rails passes value to view by assigning it to a @variable, whereas in Phoenix, render functions takes a Map (Hash or Dict) argument and passes it to the view.
7.1 Showing a single article
At this point, it should be pretty clear on how to show a single article. Basically we need to fetch an article from DB in the controller function and pass it as an assign to embedded view file. In view file, we need to add embedded code to render html tags with article details.
Let’s go through all steps.
-
Adding route: Add
get "/articles/:id", ArticleController, :showline torouter.ex. This line says that any url like"/articles/5"should call ArticleController.show function with 5 as a parameter. - Add controller code:
In
article_controller.exadd show function.def show(conn, %{"id" => id}) do render(conn, "show.html", article: Repo.get(Article, id)) endHere instead of passing params to function and retrieving id inside function body, we can use elixir’s pattern matching
%{"id" => id}to get id in declaration line itself. Then we get article from DB and pass it asarticlekey’s value inassigns. - In embedded html generate tag to show article:
We have mentioned
show.htmlin controller function. So our embedded files name must beshow.html.heex. Add following markup to this file:
<h1> <%= @article.title %> </h1>
<p>
<%= @article.body %>
</p>
This is straightforward. We take article value passed to view and show title and body.
Comparison with Rails:
Again, difference between Rails and Phoenix is minimal for this part.
7.2 Resourceful routing
Instead of adding custom route for basic CRUD operations, we can add single line to generate all routes. To do so, replace all routes we have added so far in router.ex with this line
resources "/articles", ArticleController
Now, to see which all routes are generateed and which controller function will be called for each, we should run the command mix phx.routes.
This will show a table. First column of this table is helper function which can be used to retrieve path from controller function name. Second column is HTTP method. Third is actual route and fourth column indicates controller module and function associated with this route.
E.g. article_path GET /articles/:id BlogsWeb.ArticleController :show This line tells me a GET route “/articles/:id” corresponds to :show method of ArticleController. Also, since route is parameterized, controller function will recieve key-value pair for id in the params argument.
We can use helper function to get route, so that even if route string is modified, the called function remains same. Let’s add a route to create new article in index page. Add following line in the index.html.heex before showing list of articles:
<a href={Routes.article_path(@conn, :new)}> New Article </a>
We use Routes.article_path helper function and pass :new controller name to ensure that we get route string to new article page.
Comparison with rails:
Not much difference.
7.3 Create new article
When listing routes for resources, you may have noticed two different routes for same task.
article_path GET /articles/new BlogsWeb.ArticleController :new
and
article_path POST /articles BlogsWeb.ArticleController :create
but notice first one is GET route while second one is for POST. Basic idea here is that /new route presents a page with form fields and get all necessary input for creating new resource record, which is Article in our case.
Then, on submitting the form, we should send all fields to POST route where new record will be created and saved to DB.
Corresponding controller functions should have following code:
def new(conn, _params) do
render(conn, "new.html", formdata: Article.changeset(%Article{}, %{}))
end
def create(conn, %{"article" => article}) do
%Article{} |> Article.changeset(article) |> Repo.insert
redirect(conn, to: "/")
end
Notice that create function saves Article and redirects to “/” route.
7.3.1 Creating Form
Let’s create a form and take all inputs needed to create new Article. We’ll need ‘title’ and ‘body’ input fields. While we can create plain HTML form, Phoenix provides us with some utilities which we will generate Article object instead of individual fields after submitting.
Create a new file new.html.heex and following code to create new Article form
<h2> New Article Form </h2>
<.form let={f} for={@formdata} action={Routes.article_path(@conn, :create)}>
<%= label f, :title %>
<%= text_input f, :title %>
<%= label f, :body %>
<%= textarea f, :body %>
<%= submit "Save" %>
</.form>
Explanation:
.formutility needs a changeset instead of struct- We create an empty changeset for Article in the controller function and pass it as
formdatato view - In view html file, we bind
@formdataasfusingletandforattributes. - We specify action attribute to explicitly set submit route to
:createfunction - Withing form, each element function needs associated form as an argument to bind input to corresponding model field.
Comparison with Rails:
- Rails uses empty record to bind with form, Phoenix uses changeset
- Rails form syntax feels more natural. Phoenix form syntax is yet to stabilize. They had 2 variation of
formand finally.formfor live mode. Perhaps it will become easy after maturity.
7.4 Updating an article
Similar to create, there are 3 relevent routes for update.
:updateusing PATCH:updateusing PUT:editusing GET
The usage is also similar. We use :edit to get to edit form with prefilled fields. On clicking Submit button we send updated values to :update using PATCH or PUT. I’ll pick PATCH method here.
Here are controller methods for update:
def edit(conn, %{"id" => id}) do
render(conn, "edit.html", formdata: Article.changeset(
%Article{}, Map.from_struct(Repo.get(Article, id))), id: id)
end
def update(conn, params) do
IO.inspect(params)
Repo.get(Article, params["id"]) |> Article.changeset(params["article"])
|> IO.inspect |> Repo.update
redirect(conn, to: "/")
end
Compared to creating new article, there few key changes:
- We pass id of article to be edited
- We fetch article for that id using
Repo.get - We create a
Mapfrom this article and pass it toArticle.changesetfunction. This function takes 2 arguments, first the Struct for which to create the changeset and second the map from which to fill the values. So we have to do this rather odd way of generating the changeset. - We pass both values and id to view page. id is required to generate form action link.
- In update, we first get the article using
Repo.getthen update it usingArticle.changesetfunction and finally save usingRepo.updatefunction
View page for edit similar to new page:
<h2>Article</h2>
<.form let={f} for={@formdata} action={Routes.article_path(@conn, :update, @id)} method="patch">
<%= label f, :title %>
<%= text_input f, :title %>
<%= label f, :body %>
<%= textarea f, :body %>
<%= submit "Save" %>
</.form>
We also need to update index page to add update link to this page.
<p> <a href={Routes.article_path(@conn, :edit, entry.id)}> Update </a> </p>
We added path to update with id of entry.
Comparison with Rails:
Only ORM/DB methods are slightly different. Basic workflow is same for both frameworks.
7.5 Delete an Article
Delete is easier compared to new and update workflows. We simply pass the id to be deleted, delete the article and redirect to index page.
The controller function looks like this:
def delete(conn, params) do
Repo.get(Article, params["id"]) |> Repo.delete
redirect(conn, to: "/")
end
And Delete link in index.html.heex should be updated as:
<p> <%= link "Delete", to: Routes.article_path(@conn, :delete, entry.id),
method: :delete, data: [confirm: "Are you sure?"] %> </p>
Notice few differences here:
- We use
linkfunction to generate anchor tag instead of using html tag directly - We add method
deleteso that it redirects to “/article/:id” for deletion - There is a confirmation message parameter which is shown as alert in browser window
Comparison with Rails:
Minimal difference. link function arguments are pretty much same in Rails’ link_to function
Conclusion
This concludes the tutorial. My personal objective was to give an immidiate starting point to those migrating from Rails to Phoenix. I know that this is not a self standing tutorial for anybody looking to start Phoenix directly. Perhaps in future I’ll write a complete standalone version of it.