Skip to content

Instantly share code, notes, and snippets.

@clsource
Last active July 7, 2026 20:51
Show Gist options
  • Select an option

  • Save clsource/efff616babe35c19addee1c13de670eb to your computer and use it in GitHub Desktop.

Select an option

Save clsource/efff616babe35c19addee1c13de670eb to your computer and use it in GitHub Desktop.
A phoenix framework for laravel devs

Phoenix for Laravel Devs

Init a project

Laravel has sqlite3 as the default database driver. And you can use dashes inside the directory.

Laravel

$ laravel new my-project

Phoenix

Phoenix uses Postgres as the default database driver. So we pass the database param to be equal to Laravel. You must use a valid Elixir atom (no dashes) for the project name

$ mix phx.new my_project --database=sqlite3

Start server

Laravel

$ php artisan serve

Phoenix

$ mix phx.server

And can be used the advanced iex to debug the application too

$ iex -S mix phx.server

Routes

Laravel

Defined inside routes/web.php fo web and routes/api.php for api.

Route::get("/hello", function() {
    return "Hello";
})->name("hello");

Route::get("/greet/{name}", function($name){
    return "Hello, " . $name;
});

// Redirection
Route::get("/hallo", function() {
    return redirect()->route("hello");
});

// With Controller
Route::get('/users', [UserController::class, 'index']);

// Optional Parameters
Route::get('/users/{id?}', UserController::class);

// Fallback
Route::fallback(function() {
  return "fallback";
});

Checking the routes

php artisan route:list

Phoenix

Routes are defined inside lib/my_project_web/router.ex (browser and api routes) Needs a Controller Module to render values. Does not allows anonymous functions.

 scope "/", MyAppWeb do
    pipe_through :browser

    get "/", PageController, :home
    get "/hello", PageController, :hello
    get "/greet/:name", PageController, :greet
    get "/hallo", PageController, :hallo
    
    # Fallback
    match :*, "/*path", PageController, :fallback
  end

Checking the routes

mix phx.routes

The controller is normally defined at: lib/my_project_web/controllers/

defmodule MyProjectWeb.PageController do
  use MyProjectWeb, :controller

  def hello(conn, _params) do
    text(conn, "Hello")
  end

  def greet(conn, %{"name" => name}) do
    text(conn, "Hello, #{name}")
  end
  
  # Redirection
  def hallo(conn, _params) do
    redirect(conn, to: ~p"/hello")
  end
  
  # Fallback route
  def fallback(conn, _params) do
    text(conn, "Fallback")
  end
end

You need special functions to render the output like text(conn, "my string").

There are no named routes in Phoenix. Instead you must use the sigil ~p that will ensure the route exists at compilation. This is the verified routes (https://phoenix.hexdocs.pm/Phoenix.VerifiedRoutes.html) tool that is the alternative to named routes. Named routes are arbitrary names that need to be memorized by developers, adding cognitive overhead. Verified routes tackle this problem by allowing the routes to be written as we would read them in a browser, but using the ~p sigil to guarantee they actually exist at compilation time. They remove the indirection of named routes while keeping their guarantees.

In any case, if part of your application requires features similar to named routes, then remember you can still leverage Elixir features to achieve the same result. For example, you can define several functions as named routes to be reused across modules:

def login_path, do: ~p"/login"
def user_home_path(user), do: ~p"/users/#{user.username}"

Phoenix's router uses explicit pattern matching for routes. An optional route like Laravel is not valid. Instead, Phoenix encourages defining each URL pattern explicitly, which makes routing behavior predictable and avoids ambiguity. This explicit approach is the idiomatic way to handle what would be an "optional parameter" in Laravel.

get "/users", UserController, :index
get "/users/:id", UserController, :show

Templates

Laravel

In Laravel the default template engine is Blade. The templates are by default stored inside resources/views

<h1>Hello, {{ $user->name }}</h1>

@if($user->admin)
    <p>Administrator</p>
@endif

@foreach($posts as $post)
    <p>{{ $post->title }}</p>
@endforeach

Is render inside controllers with the function view(template_file_name, assigns)

index.blade.php

Hello {{ $name }}
Route::get("/", function() {
  return view('index', ["name" => "Camilo");
});

For example if the name does not exists then it can be checked using @isset

@isset($name)
Name is: {{ $name }}
@endisset

If there is a list you can use @forelse directive to iterate them. Note that we are using a named route tasks.show to generate the url.

<ul>
@forelse ($tasks as $task)
    <li>
        <a href="{{ route("tasks.show", ["id" => $task->id]) }}">{{ $task->title }}</a>
    </li>
@empty
    <li>No Tasks</li>
@endforelse
</ul>

For a single class Laravel provides collect helpers that will enable searching items inside an array.

Route::get("/tasks/{id}", function($id) use ($tasks) {
    $task = collect($tasks)->firstWhere("id", $id);
    return view("show", ["task" => $task]);
})->name("tasks.show");

If the element is null then we can use abort() to render a 404 not found page.

if (!$task) {
    return abort(Response::HTTP_NOT_FOUND);
}

Conditionally rendering an element can be used with @if directive.

@if($task->long_description)
    <p>{{ $task->long_description }}</p>
@endif

Phoenix

In Phoenix the default template engine is Heex (HTML + Embedded Elixir).

The templates are by default stored inside lib/controllers/page_html

<h1>Hello, {@user.name}</h1>

<%= if @user.admin do %>
  <p>Administrator</p>
<% end %>

<%= for post <- @posts do %>
  <p>{post.title}</p>
<% end %>

Is rendered inside controllers with the function render(conn, :view_file_name, assigns)

index.html.heex

Hello {@name}
def home(conn, _params) do
  render(conn, :index, %{:name => "Camilo"})
end

The assigns are normally inside an assigns variable and it uses @ as a shortcut to assigns.<variable> example: @name is the short form of assigns.name. If the name does not exists then it can be checked using Map.has_key/2

<%= if Map.has_key?(assigns, :name) do %>
  {@name}
<% end %>

For a list there is no @forelse directive in Heex. It must be used plain Elixir. Additionally we use a verified route instead of a named route.

<%= if Enum.empty?(@tasks) do %>
  <p>No Tasks Found</p>
<% else %>
  <ul>
    <%= for task <- @tasks do %>
      <li>
        <a href={~p"/tasks/#{task.id}"}>{task.title}</a>
      </li>
    <% end %>
  </ul>
<% end %>

In the show template it need a simple search. Instead of Laravel's collect we can use Enum.find. It's important to use the String.to_integer/1 since the matching also is by type, and the id param will be a string.

  def task(conn, %{"id" => id}) do
    task =
      Tasks.example_tasks()
      |> Enum.find(&(&1.id == String.to_integer(id)))

    render(conn, :show, task: task)
  end

When dealing with a nil element we can use something different than Laravel abort(). It is used put_status/1 and render the text. Optionally we can use halt/1 at the end of the pipeline, although that is not normally needed.

if task == nil do
  conn
  |> put_status(:not_found)
  |> text("Not Found")
end

render(conn, :show, task: task)

Conditionally rendering an element can be used with :if directive.

<p :if={@task.long_description}>
    {@task.long_description}
</p>

Layouts

Laravel and Phoenix both have the concept of layouts, but they implement them quite differently.

Blade emphasizes flexibility and simplicity:

  • Dynamic components
  • Minimal declarations
  • Easy to learn
  • Runtime-oriented

HEEx emphasizes explicit component APIs:

  • Declared attributes
  • Declared slots
  • Validation
  • Compile-time feedback
  • Better tooling and editor support

This makes HEEx components feel closer to strongly typed UI components found in frameworks like React (with TypeScript) or Vue (with typed props), while Blade favors a more dynamic, convention-based approach.

Laravel

Layouts can be defined inside resources/views/layouts and they are normal blade templates that can be extended using @extend. The view decides which layout to extend. @yield is part of Blade's layout inheritance system. The layout defines placeholders with @yield(), and child views fill those placeholders using @section().

<!-- resources/views/layouts/app.blade.php -->

<html>
<head>
    <title>@yield('title')</title>
</head>
<body>
    @yield('content')
</body>
</html>
<!-- resources/views/home.blade.php -->

@extends('layouts.app')

@section('title', 'Home')

@section('content')
    <h1>Welcome!</h1>
    <p>This is the home page.</p>
@endsection

Components

Components are for reusable UI pieces that you can use multiple times within a page.

<!-- card.blade.php -->
<div class="card">
    {{ $slot }}
</div>
<x-card>
    Hello
</x-card>

Phoenix

Layouts can be defined inside lib/myproject_web/components/layouts. The default is called root.html.heex. Phoenix favors composition over template inheritance. The controller decides which layout to use. There is no direct equivalent to Laravel's @extend.

<!DOCTYPE html>
<html>
  <body>
    {@inner_content}
  </body>
</html>
conn
|> put_layout(html: :admin)
|> render(:index)

HEEx uses function components. With typed attributes. HEEx is designed to catch many issues before your application runs.

attr :title, :string

slot :inner_block

def card(assigns) do
  ~H"""
  <div class="card">
    <h2>{@title}</h2>

    {render_slot(@inner_block)}
  </div>
  """
end
<.card title="Users">
  Hello
</.card>

Using named and required slots.

slot :header, required: true
slot :footer, required: true
slot :inner_block, required: true

def card(assigns) do
  ~H"""
  <div>
    <header>
      {render_slot(@header)}
    </header>

    {render_slot(@inner_block)}

    <footer>
      {render_slot(@footer)}
    </footer>
  </div>
  """
end
<.card>
  <:header>
    Title
  </:header>

  Body

  <:footer>
    Footer
  </:footer>
</.card>

HEEx allows attributes on slots. Blade slots don't support declaring or validating slot-specific attributes in this way.

slot :action do
  attr :icon, :string
end
<.card>
  <:action icon="edit">
    Edit
  </:action>

  <:action icon="delete">
    Delete
  </:action>
</.card>

Example usage

<%= for action <- @action do %>
  <button>
    {action.icon}
    {render_slot(action)}
  </button>
<% end %>

Environment configuration

Environment files .env are used in Laravel, but Phoenix although can use them, it prefers other mechanism of configuration.

Laravel

Laravel was heavily influenced by PHP deployment practices. When you run a Laravel application, it automatically loads .env before almost anything else. A new developer clones the project, copies .env.example to .env, and starts working.

Historically, PHP applications were:

  • copied to a server,
  • configured by editing files,
  • run under Apache or Nginx.

Having a .env file in the project directory was simple and worked well because each deployment already had its own copy of the application.

APP_NAME=My App
DB_HOST=localhost
DB_PASSWORD=secret
// config/database.php
'password' => env('DB_PASSWORD'),

After configuration is loaded, Laravel encourages using config() instead of calling env() throughout the application.

config('database.connections.mysql.password');

Phoenix

Phoenix was designed much later, when containers and immutable deployments were becoming common. The framework aligns with the principle of build once, configure at runtime. Phoenix's approach keeps the framework independent of any particular source of configuration. The application only knows about environment variables, whether they came from a .env file, Docker, a cloud platform, or another mechanism. It only expects them to already exist when the application starts. So the responsibilities are intentionally separated. There are a few reasons behind this design: .env files are not considered a deployment standard. A .env file is primarily a convenience popularized by ecosystems like Node.js, Laravel and Ruby. Phoenix follows the broader Unix philosophy. At development it uses the shell or tooling to set environment variables. At production it lets the operating system, container, or orchestration platform provide them. This avoids making the application responsible for loading secrets. Phoenix doesn't prevent you from using .env files, it simply doesn't load one automatically. If your team prefers .env files during development, you can add a library that does exactly that.

Modern Phoenix applications typically read environment variables in config/runtime.exs and System.fetch_env!/1 reads directly from the process environment, regardless of where those variables came from instead of using and .env file. Secrets shouldn't necessarily live in the project. A .env file often ends up accidentally committed, copied around, differing between machines. Phoenix encourages secrets to come from: the OS, Docker/Kubernetes, cloud secret managers and deployment platforms rather than project files. Phoenix places a strong emphasis on releases. A release can be deployed to many environments with the same compiled artifact, while configuration is supplied at startup via environment variables. This follows the Twelve-Factor App principle of separating config from code.

config :my_app, MyApp.Repo,
  username: System.fetch_env!("DB_USERNAME"),
  password: System.fetch_env!("DB_PASSWORD")

Historically, Elixir had only config/config.exs, which was evaluated at compile time.

Compile on CI
DATABASE_URL = postgres://staging

Deploy to production
DATABASE_URL = postgres://prod

That caused problems for releases because you'd compile an application on one machine but deploy it to another with different settings. If the configuration had already been evaluated during compilation, the release would still point at staging. To solve this, Elixir introduced runtime configuration in config/runtime.exs. This file runs every time the release starts, so it sees the current environment.

if using .env files is desired, many Phoenix developers still use them locally. A common workflow is creating a local .env file and start the server loading them before.

$ source .env
$ mix phx.server

or using a one liner

$ env $(cat .env | xargs) mix phx.server
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment