Introduction: Understanding the Migration Landscape
Migrating a web application from Phoenix (Elixir) to Rails (Ruby) is a significant undertaking that involves translating not just syntax but entire architectural paradigms. While both frameworks share similarities—convention over configuration, MVC patterns, and a focus on developer productivity—the underlying languages, ecosystems, and tooling differ substantially. This guide provides a complete, step-by-step walkthrough to help you move a Phoenix application into the Rails world with confidence, preserving functionality while taking advantage of Ruby’s mature ecosystem.
Why Migrate from Phoenix to Rails?
🚀 Deploy your AI agent in 10 minutes
Managed Hermes hosting. Zero DevOps. 100M tokens/mo included.
Try it free →Organizations choose this migration for several reasons: access to a larger talent pool of Ruby developers, a broader selection of mature gems, integration with legacy Rails monoliths, or business requirements favoring Ruby infrastructure. Rails offers a massive library of battle-tested gems, extensive documentation, and a vibrant community. For teams already invested in Ruby or those finding Elixir’s functional paradigm a barrier to hiring, moving to Rails can be a strategic long-term decision.
Prerequisites and Planning
Before writing any code, perform a thorough audit. Document every route, controller action, model, schema, background job, WebSocket channel, and third-party integration. Create a spreadsheet mapping each Phoenix component to its Rails equivalent. Set up a fresh Rails application using rails new and ensure Ruby, Rails, and any required native extensions are installed. Plan for a phased rollout—perhaps using a strangler pattern—to minimize downtime and risk.
Step-by-Step Migration Guide
1. Audit Your Phoenix Application
Start by generating a comprehensive list of your application's modules and behaviors. Use mix phx.routes to capture every route. List all Ecto schemas, contexts, and migrations. Identify Phoenix channels and presence modules. Note any Oban workers or scheduled jobs. This audit becomes your blueprint for the Rails rebuild.
2. Setting Up the Rails Project
Create a new Rails application with a similar configuration (e.g., PostgreSQL, skipping unneeded frameworks). Configure the database and install essential gems like devise for authentication, pundit for authorization, and sidekiq for background jobs—matching your Phoenix dependencies as closely as possible.
rails new myapp --database=postgresql --skip-test --skip-action-mailbox --skip-action-text
cd myapp
bundle add devise pundit sidekiq
3. Translating the Database Schema and Models
Ecto Migration to Rails Migration
Convert each Ecto migration into a Rails migration. Phoenix migrations use create table blocks with add macros, while Rails uses a more imperative DSL. Pay close attention to default values, null constraints, and indexes.
Phoenix (Ecto) Migration:
defmodule MyApp.Repo.Migrations.CreateUsers do
use Ecto.Migration
def change do
create table(:users) do
add :email, :string, null: false
add :name, :string
add :role, :string, default: "user"
timestamps()
end
create unique_index(:users, [:email])
end
end
Rails Migration:
class CreateUsers < ActiveRecord::Migration[7.1]
def change
create_table :users do |t|
t.string :email, null: false
t.string :name
t.string :role, default: "user"
t.timestamps
end
add_index :users, :email, unique: true
end
end
Ecto Schema to ActiveRecord Model
Phoenix schemas are separate from the model logic (often placed in contexts). Rails merges schema definition, validations, associations, and query methods into the model class. Translate schema blocks, embedded schemas, and associations.
Phoenix Schema + Context:
defmodule MyApp.Accounts.User do
use Ecto.Schema
import Ecto.Changeset
schema "users" do
field :email, :string
field :name, :string
field :role, :string, default: "user"
has_many :posts, MyApp.Blog.Post
timestamps()
end
def changeset(user, attrs) do
user
|> cast(attrs, [:email, :name, :role])
|> validate_required([:email])
|> unique_constraint(:email)
end
end
Rails Model:
class User < ApplicationRecord
has_many :posts
validates :email, presence: true, uniqueness: true
validates :role, inclusion: { in: %w[user admin] }
end
4. Mapping Routes
Phoenix routes are defined in the router module using macros like get, post, and resources. Rails uses a similar DSL inside config/routes.rb. Translate nested resources, scopes, and pipelines (plugs) into Rails equivalents (namespaces, concerns, before_action filters).
Phoenix Router:
defmodule MyAppWeb.Router do
use Phoenix.Router
pipeline :browser do
plug :accepts, ["html"]
plug :fetch_session
end
scope "/", MyAppWeb do
pipe_through :browser
get "/", PageController, :index
resources "/posts", PostController
end
end
Rails Routes:
Rails.application.routes.draw do
root "pages#index"
resources :posts
end
5. Controllers and Actions
Phoenix controllers return rendered templates or redirects, using assigns to pass data. Rails uses instance variables (@posts) by convention. Translate plug-based authentication and authorization into before_action callbacks or gems like Devise and Pundit.
Phoenix Controller:
defmodule MyAppWeb.PostController do
use MyAppWeb, :controller
def index(conn, _params) do
posts = MyApp.Blog.list_posts()
render(conn, :index, posts: posts)
end
end
Rails Controller:
class PostsController < ApplicationController
def index
@posts = Post.all
end
end
6. Views and Templates
Replace Embedded Elixir (.eex / .heex) templates with ERB (.erb). The syntax differences are minor: <%= @post.title %> works in both, but Rails uses instance variables from the controller. For component-like structures, consider Rails ViewComponent or partials with locals.
Phoenix Template (HEEX):
<h1>Posts</h1>
<%= for post <- @posts do %>
<div class="post">
<h2><%= post.title %></h2>
<p><%= post.body %></p>
</div>
<% end %>
Rails ERB Template:
<h1>Posts</h1>
<% @posts.each do |post| %>
<div class="post">
<h2><%= post.title %></h2>
<p><%= post.body %></p>
</div>
<% end %>
7. Channels to Action Cable
Phoenix Channels provide WebSocket-based real-time communication with topics and a client library. Rails offers Action Cable with similar concepts: channels, connections, and broadcasting. Translate your Phoenix socket definitions and channel modules into Action Cable channels.
Phoenix Channel:
defmodule MyAppWeb.PostChannel do
use Phoenix.Channel
def join("post:" <> post_id, _payload, socket) do
{:ok, socket}
end
def handle_in("new_comment", payload, socket) do
broadcast!(socket, "new_comment", payload)
{:noreply, socket}
end
end
Rails Action Cable Channel:
class PostChannel < ApplicationCable::Channel
def subscribed
stream_from "post:#{params[:post_id]}"
end
def receive(data)
ActionCable.server.broadcast("post:#{params[:post_id]}", data)
end
end
8. Background Jobs: Oban to Sidekiq
Oban uses Ecto-backed job queues with worker modules. Sidekiq relies on Redis and defines workers as classes. Translate periodic jobs (Oban Cron) into Sidekiq's scheduler or use whenever gem for cron-style scheduling.
Phoenix Oban Worker:
defmodule MyApp.Workers.SendEmail do
use Oban.Worker
def perform(%{"email" => email}) do
MyApp.Email.send_welcome(email)
:ok
end
end
Sidekiq Worker:
class SendEmailWorker
include Sidekiq::Worker
def perform(email)
UserMailer.welcome(email).deliver_now
end
end
9. Testing and Quality Assurance
Phoenix uses ExUnit with factory libraries like ExMachina. Rails uses RSpec or Minitest. Convert your test suite systematically: controller tests become Rails functional/request specs, model tests become model specs, and channel tests become Action Cable channel specs. Use FactoryBot for test data. Validate every behavior in a staging environment before going live.
10. Deployment and Rollout
Deploy the Rails application alongside the Phoenix one, using a reverse proxy (Nginx or a load balancer) to gradually route traffic. Start with read-only endpoints, then move write operations. Monitor performance, errors, and background jobs closely. Once the Rails app handles all traffic confidently, decommission the Phoenix instance.
Best Practices for a Smooth Transition
- Use a strangler pattern: Migrate incrementally, not with a big bang rewrite. Keep both apps running and shift traffic gradually.
- Maintain feature parity: Don’t add new features during the migration; focus on replicating existing behavior exactly.
- Automate comparisons: Write scripts that compare responses between Phoenix and Rails for identical inputs to catch discrepancies early.
- Leverage Rails generators: Use scaffolds and generators to speed up boilerplate, then customize to match Phoenix behavior.
- Handle sessions and authentication carefully: Ensure user sessions transfer seamlessly; consider a shared token store or cookie domain changes.
- Monitor and rollback plans: Have a clear rollback strategy for each step, with feature flags or DNS toggles.
Conclusion
Migrating from Phoenix to Rails is a multi-stage journey that touches every layer of your application. By following a structured, step-by-step approach—auditing, translating schemas, routes, controllers, views, real-time features, and jobs—you can systematically rebuild your system on a new foundation without losing functionality. The process demands patience, rigorous testing, and careful rollout planning. With the right preparation and a commitment to incremental delivery, your team can successfully complete the transition and unlock the full benefits of the Rails ecosystem.