Ruby on Rails vs Phoenix
Choosing a web framework is not just about syntax, it is about the trade-offs that define your app’s future. Do you want the speed of development that made Ruby on Rails famous, or the real-time performance that powers Phoenix?
Rails has spent two decades perfecting conventions that let developers ship features in hours, not weeks. Phoenix, built on Elixir and the battle-tested Erlang VM, brings unmatched scalability and live updates that today’s users expect.
Both frameworks are developer-friendly, but they prioritize different goals. Rails maximizes productivity through conventions, while Phoenix pushes performance and concurrency to the forefront. Picking one shapes everything from your development workflow to how your app scales under load.
What is Ruby on Rails?
Rails solved the biggest problem in 2004 web development: every developer was rebuilding the same features over and over. User authentication, database migrations, form handling, and URL routing had to be written from scratch for each project. Rails bundled all these common needs into one framework with sensible defaults.
The magic happens through conventions that eliminate decisions. Put your models in the models
folder, controllers in controllers
, and Rails knows how to connect everything. Need user management? Generate a scaffold and get working code in seconds. Want to add a new feature? Follow the established patterns and everything just works.
Rails handles database relationships, validation, scoping, authentication, and error handling without any configuration. The framework makes assumptions about what you need and provides it automatically.
What is Phoenix?
Phoenix was created because modern web applications need capabilities that traditional frameworks struggle with. Users expect real-time updates, instant responses, and applications that stay fast under heavy load. Phoenix delivers these features while keeping development simple and enjoyable.
The secret is Elixir and the Erlang virtual machine, which were designed for telecommunications systems that never go down. Your Phoenix app runs millions of lightweight processes that can communicate instantly. When a user action needs to update other users in real-time, Phoenix makes it effortless.
Phoenix code looks similar to Rails but runs on a completely different foundation. Pattern matching makes error handling explicit and reliable, while the underlying virtual machine handles massive concurrency automatically.
Framework comparison
Understanding what each framework prioritizes helps you pick the right tool for your project.
Aspect | Ruby on Rails | Phoenix |
---|---|---|
Language | Ruby - readable, expressive | Elixir - functional, pattern matching |
Performance | Good for most apps, slower under high load | Exceptional, handles millions of connections |
Learning Curve | Gentle, lots of tutorials and books | Steeper, functional programming concepts |
Development Speed | Very fast, lots of generators and gems | Fast, but fewer ready-made solutions |
Real-time Features | ActionCable and Hotwire Turbo | Built-in LiveView and Channels |
Community | Huge, mature ecosystem (15+ years) | Growing, enthusiastic but smaller (10+ years) |
Hosting | Works everywhere, many deployment options | Fewer hosting options, containerization common |
Job Market | Lots of Rails jobs available | Fewer Phoenix jobs, but growing demand |
Error Handling | Exceptions, can crash entire request | Supervised processes, failures stay isolated |
Database | ActiveRecord, works with any SQL database | Ecto, PostgreSQL recommended |
Your choice depends on what matters most for your project. Rails excels when you need to ship features quickly and have a team that knows Ruby. Phoenix shines when performance and real-time features are critical.
Getting started
Let's see how quickly you can get a working application with each framework.
Rails gets you from zero to running app in minutes:
gem install rails
rails new blog_app
cd blog_app
rails generate scaffold Article title:string content:text published:boolean
rails db:migrate
rails server
This creates a complete blog application with database, forms, and styling. You can immediately create, edit, and delete articles through a web interface.
class Article < ApplicationRecord
belongs_to :user
has_many :comments, dependent: :destroy
validates :title, presence: true
validates :content, presence: true, length: { minimum: 10 }
scope :published, -> { where(published: true) }
scope :recent, -> { order(created_at: :desc) }
end
class ArticlesController < ApplicationController
before_action :authenticate_user!, except: [:index, :show]
before_action :set_article, only: [:show, :edit, :update, :destroy]
def index
@articles = Article.published.recent.includes(:user)
end
def create
@article = current_user.articles.build(article_params)
if @article.save
redirect_to @article, notice: 'Article created successfully!'
else
render :new
end
end
private
def article_params
params.require(:article).permit(:title, :content, :published)
end
end
Rails.application.routes.draw do
resources :articles
root 'articles#index'
end
Rails generates everything you need: database migrations, model validations, controller actions, HTML views, and CSS styling. You get a working application without writing any code yourself.
Phoenix requires a bit more setup but gives you a solid foundation:
mix archive.install hex phx_new
mix phx.new blog_app
cd blog_app
mix ecto.setup
mix phx.gen.html Blog Article articles title:string content:text published:boolean
mix ecto.migrate
mix phx.server
Phoenix generates similar functionality but with different patterns:
defmodule Blog.Article do
use Ecto.Schema
import Ecto.Changeset
schema "articles" do
field :title, :string
field :content, :string
field :published, :boolean, default: false
belongs_to :user, Blog.User
has_many :comments, Blog.Comment
timestamps()
end
def changeset(article, attrs) do
article
|> cast(attrs, [:title, :content, :published])
|> validate_required([:title, :content])
|> validate_length(:content, min: 10)
end
end
defmodule BlogWeb.ArticleController do
use BlogWeb, :controller
alias Blog.Articles
def index(conn, _params) do
articles = Articles.list_published_articles()
render(conn, "index.html", articles: articles)
end
def create(conn, %{"article" => article_params}) do
case Articles.create_article(article_params) do
{:ok, article} ->
conn
|> put_flash(:info, "Article created successfully!")
|> redirect(to: ~p"/articles/#{article}")
{:error, changeset} ->
render(conn, "new.html", changeset: changeset)
end
end
end
defmodule BlogWeb.Router do
use BlogWeb, :router
scope "/", BlogWeb do
pipe_through :browser
get "/", PageController, :index
resources "/articles", ArticleController
end
end
Phoenix generators create similar structure to Rails but use Elixir patterns. The result is a working web application that can handle much higher traffic than the Rails version.
Real-time features
Modern web apps need to update instantly when data changes. This is where the two frameworks differ most dramatically.
Rails provides real-time features through ActionCable, but it requires careful setup:
class CommentsChannel < ApplicationCable::Channel
def subscribed
stream_from "article_#{params[:article_id]}_comments"
end
def unsubscribed
# Cleanup code here
end
end
class Comment < ApplicationRecord
belongs_to :article
after_create_commit do
ActionCable.server.broadcast(
"article_#{article_id}_comments",
comment: CommentsController.render(partial: 'comment', locals: { comment: self })
)
end
end
import consumer from "./consumer"
consumer.subscriptions.create({
channel: "CommentsChannel",
article_id: window.articleId
}, {
connected() {
console.log("Connected to comments channel")
},
received(data) {
document.querySelector("#comments").insertAdjacentHTML('beforeend', data.comment)
}
})
ActionCable works but requires coordinating between Ruby, JavaScript, and WebSocket connections. Each piece needs careful configuration to work together.
Phoenix makes real-time features simple with LiveView:
defmodule BlogWeb.ArticleLive do
use BlogWeb, :live_view
alias Blog.Articles
def mount(%{"id" => id}, _session, socket) do
if connected?(socket), do: Articles.subscribe_to_comments(id)
article = Articles.get_article!(id)
{:ok, assign(socket, :article, article)}
end
def handle_event("add_comment", %{"comment" => comment_params}, socket) do
case Articles.create_comment(socket.assigns.article, comment_params) do
{:ok, _comment} ->
{:noreply, put_flash(socket, :info, "Comment added!")}
{:error, _changeset} ->
{:noreply, put_flash(socket, :error, "Could not add comment")}
end
end
def handle_info({:comment_created, comment}, socket) do
updated_article = Articles.get_article!(socket.assigns.article.id)
{:noreply, assign(socket, :article, updated_article)}
end
end
<div>
<h1><%= @article.title %></h1>
<p><%= @article.content %></p>
<div id="comments">
<%= for comment <- @article.comments do %>
<div class="comment">
<p><%= comment.body %></p>
</div>
<% end %>
</div>
<form phx-submit="add_comment">
<input type="text" name="comment[body]" placeholder="Add a comment..." />
<button type="submit">Post Comment</button>
</form>
</div>
LiveView handles all the real-time complexity for you. When someone adds a comment, all users see it instantly without any JavaScript or WebSocket configuration. The HTML updates automatically.
Database and querying
How you work with data shapes your entire application. Rails and Phoenix take different approaches to database interaction.
Rails uses ActiveRecord, which tries to hide database complexity:
class Article < ApplicationRecord
belongs_to :user
has_many :comments, dependent: :destroy
has_many :tags, through: :article_tags
validates :title, presence: true, uniqueness: true
validates :content, presence: true
scope :published, -> { where(published: true) }
scope :popular, -> { joins(:comments).group(:id).having('count(comments.id) > 5') }
def self.search(query)
where("title ILIKE ? OR content ILIKE ?", "%#{query}%", "%#{query}%")
end
end
def index
@articles = Article.published
.includes(:user, :comments)
.where(created_at: 1.month.ago..)
.order(created_at: :desc)
.page(params[:page])
end
ActiveRecord lets you write Ruby code that becomes SQL queries. It prevents many SQL injection attacks and handles database differences automatically. However, complex queries can become hard to optimize.
Phoenix uses Ecto, which makes database queries explicit and composable:
defmodule Blog.Articles do
import Ecto.Query
alias Blog.{Repo, Article, Comment}
def list_published_articles do
Article
|> where([a], a.published == true)
|> where([a], a.inserted_at > ago(1, "month"))
|> order_by([a], desc: a.inserted_at)
|> preload([:user, :comments])
|> Repo.all()
end
def search_articles(query) do
search_term = "%#{query}%"
Article
|> where([a], ilike(a.title, ^search_term) or ilike(a.content, ^search_term))
|> where([a], a.published == true)
|> Repo.all()
end
def popular_articles do
Comment
|> group_by([c], c.article_id)
|> having([c], count(c.id) > 5)
|> select([c], c.article_id)
|> join(:inner, [c], a in Article, on: c.article_id == a.id)
|> preload([c, a], [article: [:user]])
|> Repo.all()
end
end
Ecto queries look more like SQL but with Elixir syntax. This makes performance optimization easier because you can see exactly what database operations happen. The ^
pins variables to prevent injection attacks.
Performance under load
Real applications need to handle traffic spikes, slow database queries, and user growth. This is where Rails and Phoenix show their biggest differences.
Rails performance depends heavily on configuration and caching:
Rails.application.configure do
config.cache_classes = true
config.cache_store = :redis_cache_store, { url: ENV['REDIS_URL'] }
# Enable serving of images, stylesheets, and JavaScripts from an asset server.
config.asset_host = ENV['CDN_URL']
# Use a different logger for distributed setups.
config.logger = ActiveSupport::TaggedLogging.new(Logger.new(STDOUT))
end
class ArticlesController < ApplicationController
def index
@articles = Rails.cache.fetch("articles/published", expires_in: 5.minutes) do
Article.published.includes(:user).limit(20)
end
end
def show
@article = Rails.cache.fetch("article/#{params[:id]}", expires_in: 1.hour) do
Article.find(params[:id])
end
end
end
Rails apps scale through caching, background jobs, and adding more servers. Each Rails process handles one request at a time, so you need multiple processes to handle concurrent users.
Phoenix handles concurrency differently through the actor model:
defmodule BlogWeb.ArticleController do
use BlogWeb, :controller
def index(conn, _params) do
# This can handle thousands of concurrent requests
articles = Blog.Articles.list_published_articles()
render(conn, "index.html", articles: articles)
end
end
defmodule Blog.Cache do
use GenServer
def start_link(opts) do
GenServer.start_link(__MODULE__, %{}, opts)
end
def get(key) do
GenServer.call(__MODULE__, {:get, key})
end
def put(key, value) do
GenServer.cast(__MODULE__, {:put, key, value})
end
def handle_call({:get, key}, _from, state) do
{:reply, Map.get(state, key), state}
end
def handle_cast({:put, key, value}, state) do
{:noreply, Map.put(state, key, value)}
end
end
Phoenix applications can handle hundreds of thousands of concurrent connections on a single server. When one request is waiting for the database, thousands of other requests continue processing.
Testing
Every production application needs thorough testing to catch bugs before users do. Rails and Phoenix provide different testing tools and philosophies.
Rails testing is comprehensive and convention-based:
require 'test_helper'
class ArticleTest < ActiveSupport::TestCase
test "should not save article without title" do
article = Article.new(content: "Some content")
assert_not article.save
assert_includes article.errors[:title], "can't be blank"
end
test "should create article with valid attributes" do
article = Article.new(
title: "Test Article",
content: "This is test content",
user: users(:john)
)
assert article.save
assert_equal "Test Article", article.title
end
end
require 'test_helper'
class ArticlesControllerTest < ActionDispatch::IntegrationTest
setup do
@article = articles(:one)
@user = users(:john)
end
test "should get index" do
get articles_url
assert_response :success
assert_select "h1", "Articles"
end
test "should create article when logged in" do
sign_in @user
assert_difference('Article.count') do
post articles_url, params: {
article: { title: "New Article", content: "Content here" }
}
end
assert_redirected_to article_url(Article.last)
end
end
Rails testing covers models, controllers, and integration flows with helpful assertions and fixtures. The testing framework knows about Rails conventions and provides shortcuts for common testing patterns.
Phoenix testing emphasizes pattern matching and explicit assertions:
defmodule Blog.ArticlesTest do
use Blog.DataCase
alias Blog.Articles
describe "create_article/1" do
test "creates article with valid data" do
valid_attrs = %{title: "Test Article", content: "Test content"}
assert {:ok, %Article{} = article} = Articles.create_article(valid_attrs)
assert article.title == "Test Article"
assert article.content == "Test content"
end
test "returns error with invalid data" do
assert {:error, %Ecto.Changeset{}} = Articles.create_article(%{})
end
end
end
defmodule BlogWeb.ArticleControllerTest do
use BlogWeb.ConnCase
describe "GET /articles" do
test "lists all articles", %{conn: conn} do
article = insert(:article, title: "Test Article")
conn = get(conn, Routes.article_path(conn, :index))
assert html_response(conn, 200) =~ "Test Article"
end
end
describe "POST /articles" do
test "creates article with valid data", %{conn: conn} do
article_params = %{title: "New Article", content: "Content"}
conn = post(conn, Routes.article_path(conn, :create), article: article_params)
assert %{id: id} = redirected_params(conn)
assert redirected_to(conn) == Routes.article_path(conn, :show, id)
end
end
end
Phoenix tests use pattern matching to verify exact return values. The {:ok, article}
and {:error, changeset}
patterns make success and failure cases explicit.
Final thoughts
This article showed you the practical differences between Rails and Phoenix for building web applications. Each framework solves different problems well. The decision often comes down to you requirements. If you need to move fast and your team knows Ruby, Rails will get you there quicker. If you need performance and real-time features, Phoenix provides capabilities that Rails can't match easily.