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 mix
command. 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 (
li
in 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, :show
line 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.ex
add show function.def show(conn, %{"id" => id}) do render(conn, "show.html", article: Repo.get(Article, id)) end
Here 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 asarticle
key’s value inassigns
. - In embedded html generate tag to show article:
We have mentioned
show.html
in 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:
.form
utility needs a changeset instead of struct- We create an empty changeset for Article in the controller function and pass it as
formdata
to view - In view html file, we bind
@formdata
asf
usinglet
andfor
attributes. - We specify action attribute to explicitly set submit route to
:create
function - 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
form
and finally.form
for live mode. Perhaps it will become easy after maturity.
7.4 Updating an article
Similar to create, there are 3 relevent routes for update.
:update
using PATCH:update
using PUT:edit
using 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
Map
from this article and pass it toArticle.changeset
function. 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.get
then update it usingArticle.changeset
function and finally save usingRepo.update
function
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
link
function to generate anchor tag instead of using html tag directly - We add method
delete
so 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.