skills/elixir/elixir-part3-phoenix.md

27 KiB

Elixir Part 3: Phoenix Framework — v1.8.5 (Current)

Phoenix is the web framework for Elixir. Version 1.8.5 is current. It provides MVC controllers, real-time LiveView, Channels for WebSocket communication, and comprehensive tooling for authentication, testing, and deployment.

What's New in Phoenix 1.8

  • Scopes in generators — secure data access by default (e.g., current_user automatically applied)
  • Magic links (passwordless auth) and "sudo mode" in mix phx.gen.auth
  • daisyUI integration — light/dark/system mode support out of the box
  • Simplified layouts — single root.html.heex wraps everything; dynamic layouts are function components
  • use Phoenix.Controller now requires :formats — must specify formats: [:html] or formats: [:html, :json]
  • Updated security headerscontent-security-policy with base-uri 'self'; frame-ancestors 'self'; dropped deprecated x-frame-options and x-download-options
  • config variable removed from Phoenix.Endpoint — use Application.compile_env/3 instead
  • Deprecated: :namespace, :put_default_views, layouts without modules, :trailing_slash in router

Project Setup

# New project (Phoenix Express — quickest path)
curl https://new.phoenixframework.org/my_app | sh

# Traditional setup
mix phx.new my_app
mix phx.new my_app --no-ecto    # Without database
mix phx.new my_app --no-html    # API only
mix phx.new my_app --database sqlite3  # SQLite instead of Postgres

Directory Structure

my_app/
├── lib/
│   ├── my_app/              # Business logic (contexts, schemas)
│   │   ├── application.ex   # Supervision tree
│   │   ├── repo.ex          # Ecto Repo
│   │   └── catalog/         # Context: Catalog
│   │       ├── product.ex   # Schema
│   │       └── catalog.ex   # Context module
│   └── my_app_web/          # Web layer
│       ├── endpoint.ex      # HTTP entry point
│       ├── router.ex        # Routes + pipelines
│       ├── components/
│       │   ├── core_components.ex  # Shared UI components
│       │   └── layouts.ex          # Layout components
│       ├── controllers/
│       │   ├── page_controller.ex
│       │   └── page_html/          # Templates for controller
│       │       └── home.html.heex
│       └── live/             # LiveView modules
│           └── counter_live.ex
├── config/
│   ├── config.exs            # Compile-time config
│   ├── dev.exs / prod.exs
│   └── runtime.exs           # Runtime config (env vars, secrets)
├── priv/
│   ├── repo/migrations/
│   └── static/               # Static assets
├── test/
│   ├── support/
│   │   ├── conn_case.ex      # Test helpers for controllers
│   │   └── data_case.ex      # Test helpers for data layer
│   └── my_app_web/
└── mix.exs

Router & Pipelines

defmodule MyAppWeb.Router do
  use MyAppWeb, :router

  pipeline :browser do
    plug :accepts, ["html"]
    plug :fetch_session
    plug :fetch_live_flash
    plug :put_root_layout, html: {MyAppWeb.Layouts, :root}
    plug :protect_from_forgery
    plug :put_secure_browser_headers
  end

  pipeline :api do
    plug :accepts, ["json"]
  end

  scope "/", MyAppWeb do
    pipe_through :browser

    get "/", PageController, :home
    live "/counter", CounterLive         # LiveView route
    resources "/products", ProductController  # RESTful CRUD
  end

  scope "/api", MyAppWeb do
    pipe_through :api

    resources "/items", ItemController, except: [:new, :edit]
  end
end

Verified Routes — ~p Sigil

Always use ~p instead of string paths. Compile-time verified against your router.

# In templates (HEEx)
~H"""
<a href={~p"/products/#{product}"}>View</a>
<a href={~p"/products/#{product}/edit"}>Edit</a>
"""

# In controllers
redirect(conn, to: ~p"/products/#{product}")

# With query params
~p"/products?page=#{page}&sort=name"
~p"/products?#{%{page: 1, sort: "name"}}"

# URL generation (includes host)
url(~p"/products/#{product}")
# => "https://example.com/products/42"

Controllers (1.8 Style)

defmodule MyAppWeb.ProductController do
  use MyAppWeb, :controller

  # REQUIRED in 1.8: specify formats
  # This is typically set in MyAppWeb :controller function

  def index(conn, _params) do
    products = Catalog.list_products()
    render(conn, :index, products: products)
  end

  def show(conn, %{"id" => id}) do
    product = Catalog.get_product!(id)
    render(conn, :show, product: product)
  end

  def create(conn, %{"product" => product_params}) do
    case Catalog.create_product(product_params) do
      {:ok, product} ->
        conn
        |> put_flash(:info, "Product created.")
        |> redirect(to: ~p"/products/#{product}")

      {:error, %Ecto.Changeset{} = changeset} ->
        render(conn, :new, changeset: changeset)
    end
  end
end

View module naming: For ProductController, Phoenix looks for ProductHTML (for :html format) and ProductJSON (for :json format).

# lib/my_app_web/controllers/product_html.ex
defmodule MyAppWeb.ProductHTML do
  use MyAppWeb, :html
  embed_templates "product_html/*"  # Loads .heex files from directory
end

# lib/my_app_web/controllers/product_json.ex
defmodule MyAppWeb.ProductJSON do
  def index(%{products: products}), do: %{data: for(p <- products, do: data(p))}
  def show(%{product: product}), do: %{data: data(product)}
  defp data(product), do: %{id: product.id, title: product.title, price: product.price}
end

Components and HEEx

defmodule MyAppWeb.CoreComponents do
  use Phoenix.Component

  # Declare attributes with types and docs
  attr :type, :string, default: "button"
  attr :class, :string, default: nil
  attr :rest, :global  # Passes through all other HTML attrs
  slot :inner_block, required: true

  def button(assigns) do
    ~H"""
    <button type={@type} class={["btn", @class]} {@rest}>
      {render_slot(@inner_block)}
    </button>
    """
  end

  # Table component with slots
  attr :rows, :list, required: true
  slot :col, required: true do
    attr :label, :string
  end

  def table(assigns) do
    ~H"""
    <table>
      <thead>
        <tr>
          <th :for={col <- @col}>{col[:label]}</th>
        </tr>
      </thead>
      <tbody>
        <tr :for={row <- @rows}>
          <td :for={col <- @col}>{render_slot(col, row)}</td>
        </tr>
      </tbody>
    </table>
    """
  end
end

HEEx syntax notes:

  • {@var} — render assign (curly braces, not <%= %>)
  • :if={condition} — conditional rendering on any tag
  • :for={item <- list} — iteration on any tag
  • <.component_name /> — call function component with dot notation
  • <Component.name /> — call remote component with module name
  • {render_slot(@inner_block)} — render slot content
  • <:slot_name>content</:slot_name> — named slot content

LiveView

defmodule MyAppWeb.SearchLive do
  use MyAppWeb, :live_view

  def mount(_params, _session, socket) do
    {:ok, assign(socket, query: "", results: [])}
  end

  def handle_params(%{"q" => query}, _uri, socket) do
    {:noreply, assign(socket, query: query, results: search(query))}
  end

  def handle_params(_params, _uri, socket), do: {:noreply, socket}

  def handle_event("search", %{"query" => query}, socket) do
    {:noreply,
     socket
     |> assign(query: query, results: search(query))
     |> push_patch(to: ~p"/search?q=#{query}")}
  end

  def render(assigns) do
    ~H"""
    <Layouts.app flash={@flash}>
      <form phx-submit="search">
        <input name="query" value={@query} phx-debounce="300" />
        <button type="submit">Search</button>
      </form>

      <div :for={result <- @results} class="result">
        <h3>{result.title}</h3>
        <p>{result.summary}</p>
      </div>
    </Layouts.app>
    """
  end

  defp search(query), do: MyApp.Search.find(query)
end

LiveView lifecycle: mount/3handle_params/3render/1. Events via handle_event/3. Server pushes via handle_info/2.

Key patterns:

  • assign/2,3 — set socket assigns
  • push_navigate/2 — navigate to new LiveView (full mount)
  • push_patch/2 — update URL without full remount (calls handle_params)
  • push_event/3 — push event to client JS hooks
  • stream/3,4 — efficient list rendering for large collections (inserts/deletes without re-rendering entire list)
  • async_assign/3 + assign_async/3 — async data loading with loading/error states

Channels — Real-Time WebSocket

# In endpoint.ex
socket "/socket", MyAppWeb.UserSocket,
  websocket: true,
  longpoll: false

# lib/my_app_web/channels/user_socket.ex
defmodule MyAppWeb.UserSocket do
  use Phoenix.Socket

  channel "room:*", MyAppWeb.RoomChannel

  def connect(%{"token" => token}, socket, _connect_info) do
    case Phoenix.Token.verify(socket, "user auth", token, max_age: 86400) do
      {:ok, user_id} -> {:ok, assign(socket, :user_id, user_id)}
      {:error, _} -> :error
    end
  end

  def id(socket), do: "users_socket:#{socket.assigns.user_id}"
end

# lib/my_app_web/channels/room_channel.ex
defmodule MyAppWeb.RoomChannel do
  use MyAppWeb, :channel

  def join("room:" <> room_id, _params, socket) do
    {:ok, assign(socket, :room_id, room_id)}
  end

  def handle_in("new_msg", %{"body" => body}, socket) do
    broadcast!(socket, "new_msg", %{
      body: body,
      user_id: socket.assigns.user_id
    })
    {:noreply, socket}
  end
end

Force disconnect all sessions for a user:

MyAppWeb.Endpoint.broadcast("users_socket:#{user.id}", "disconnect", %{})

Contexts — Business Logic Boundary

Contexts are plain Elixir modules that encapsulate data access and business rules. They are the API between your web layer and your domain.

# Generate with: mix phx.gen.context Catalog Product products title:string price:decimal
defmodule MyApp.Catalog do
  import Ecto.Query
  alias MyApp.Repo
  alias MyApp.Catalog.Product

  def list_products do
    Repo.all(Product)
  end

  def get_product!(id), do: Repo.get!(Product, id)

  def create_product(attrs \\ %{}) do
    %Product{}
    |> Product.changeset(attrs)
    |> Repo.insert()
  end

  def update_product(%Product{} = product, attrs) do
    product
    |> Product.changeset(attrs)
    |> Repo.update()
  end

  def delete_product(%Product{} = product) do
    Repo.delete(product)
  end

  def change_product(%Product{} = product, attrs \\ %{}) do
    Product.changeset(product, attrs)
  end
end

Context design principles:

  • One context per bounded domain (Catalog, Accounts, Orders)
  • Contexts own their schemas — other contexts reference by ID, not struct
  • Cross-context calls go through the public context API, never access another context's Repo directly
  • Contexts can nest related schemas (Comments under Posts)

Authentication — mix phx.gen.auth

mix phx.gen.auth Accounts User users

Phoenix 1.8 generates:

  • Magic links (passwordless) — email-based login links
  • "Sudo mode" — re-authentication for sensitive actions
  • Session-based auth with secure token handling
  • Email confirmation and password reset flows
  • require_authenticated_user plug for protected routes

Scopes (New in 1.8)

Scopes make secure data access the default in generators. When you generate resources with a scope, all queries are automatically filtered by the scoped user.

mix phx.gen.live Posts Post posts title body:text --scope current_user

This generates code that automatically passes current_user to context functions, ensuring users only see their own data.


Ecto — Database Layer

Ecto has four core components: Repo (database wrapper), Schema (data mapping), Changeset (validation + change tracking), and Query (composable queries). Always preload associations explicitly — Ecto never lazy-loads.

Schema

defmodule MyApp.Catalog.Product do
  use Ecto.Schema
  import Ecto.Changeset

  schema "products" do
    field :title, :string
    field :price, :decimal
    field :status, Ecto.Enum, values: [:draft, :published, :archived]
    field :metadata, :map, default: %{}
    field :tags, {:array, :string}, default: []
    field :computed, :string, virtual: true       # Not persisted
    field :slug, :string, source: :url_slug       # Maps to different DB column

    has_many :reviews, MyApp.Reviews.Review
    has_one :detail, MyApp.Catalog.ProductDetail
    belongs_to :category, MyApp.Catalog.Category
    many_to_many :tags, MyApp.Tag, join_through: "product_tags"

    embeds_one :seo, SEO, on_replace: :update do
      field :meta_title, :string
      field :meta_description, :string
    end

    embeds_many :variants, Variant, on_replace: :delete do
      field :sku, :string
      field :price, :decimal
    end

    timestamps(type: :utc_datetime)
  end

  def changeset(product, attrs) do
    product
    |> cast(attrs, [:title, :price, :status, :category_id, :metadata, :tags])
    |> cast_embed(:seo)
    |> cast_embed(:variants)
    |> validate_required([:title, :price])
    |> validate_number(:price, greater_than: 0)
    |> validate_length(:title, min: 3, max: 255)
    |> validate_inclusion(:status, [:draft, :published, :archived])
    |> unique_constraint(:title)
    |> foreign_key_constraint(:category_id)
  end
end

Field types: :string, :integer, :float, :decimal, :boolean, :binary, :map, {:array, type}, :date, :time, :naive_datetime, :utc_datetime, :utc_datetime_usec, :binary_id, Ecto.UUID, Ecto.Enum

Field options: :default, :source (DB column name), :virtual (not persisted), :redact (mask in inspect), :read_after_writes (re-read from DB post-write), :autogenerate

Embed :on_replace options: :raise (default), :mark_as_invalid, :update (embeds_one), :delete (embeds_many)

Changeset — Validation & Change Tracking

# Validations (run in-memory, no DB)
|> validate_required([:field])
|> validate_format(:email, ~r/@/)
|> validate_length(:name, min: 2, max: 100)
|> validate_number(:age, greater_than: 0, less_than: 150)
|> validate_inclusion(:role, [:admin, :user])
|> validate_exclusion(:username, ["admin", "root"])
|> validate_acceptance(:terms)                    # Checkbox must be true
|> validate_confirmation(:password)               # password_confirmation must match
|> validate_subset(:permissions, [:read, :write, :admin])
|> validate_change(:email, fn :email, email ->    # Custom per-field validation
     if String.contains?(email, "+"), do: [email: "no plus addressing"], else: []
   end)

# Constraints (checked by DB — only run if validations pass)
|> unique_constraint(:email)
|> unique_constraint([:user_id, :project_id], name: :user_projects_unique_index)
|> foreign_key_constraint(:category_id)
|> check_constraint(:price, name: :price_must_be_positive)
|> no_assoc_constraint(:reviews)                  # Prevent delete if has associations
|> exclusion_constraint(:date_range)              # Postgres range exclusion

Schemaless changesets — validate arbitrary data without a schema:

types = %{email: :string, age: :integer}
changeset = {%{}, types}
  |> Ecto.Changeset.cast(params, Map.keys(types))
  |> Ecto.Changeset.validate_required([:email])

case Ecto.Changeset.apply_action(changeset, :validate) do
  {:ok, data} -> use_data(data)
  {:error, changeset} -> show_errors(changeset)
end

Error traversal:

Ecto.Changeset.traverse_errors(changeset, fn {msg, opts} ->
  Regex.replace(~r"%{(\w+)}", msg, fn _, key ->
    opts |> Keyword.get(String.to_existing_atom(key), key) |> to_string()
  end)
end)
# => %{title: ["can't be blank"], price: ["must be greater than 0"]}

Query — Composable & Dynamic

import Ecto.Query

# Composable queries — chain them together
def published(query \\ Product) do
  from p in query, where: p.status == :published
end

def recent(query, days \\ 7) do
  from p in query, where: p.inserted_at > ago(^days, "day")
end

def with_reviews(query) do
  from p in query, preload: [:reviews]
end

# Usage: Product |> published() |> recent(30) |> with_reviews() |> Repo.all()

Named bindings — track joins without positional counting:

from p in Post, as: :post,
  join: c in assoc(p, :comments), as: :comment,
  where: as(:comment).approved == true,
  select: {as(:post).title, count(as(:comment).id)},
  group_by: as(:post).id

Dynamic queries — build conditions programmatically:

def filter(params) do
  query = from(p in Product)

  conditions = true

  conditions = if params["status"],
    do: dynamic([p], p.status == ^params["status"] and ^conditions),
    else: conditions

  conditions = if params["min_price"],
    do: dynamic([p], p.price >= ^params["min_price"] and ^conditions),
    else: conditions

  from p in query, where: ^conditions
end

Subqueries:

top_products = from p in Product, order_by: [desc: :sales], limit: 10
from p in subquery(top_products), select: avg(p.price)

Window functions:

from e in Employee,
  select: {e.name, e.salary, over(avg(e.salary), :dept)},
  windows: [dept: [partition_by: e.department_id, order_by: e.salary]]

Fragments (raw SQL):

from p in Post,
  where: fragment("? @> ?", p.tags, ^["elixir"]),   # Postgres array contains
  order_by: fragment("random()")

Preloading strategies:

# Separate queries (default) — parallelized, no data duplication
Repo.all(Post) |> Repo.preload([:comments, :author])

# Join preload — single query, watch for Cartesian product
from p in Post, join: c in assoc(p, :comments), preload: [comments: c]

# Custom query preload
Repo.preload(posts, comments: from(c in Comment, order_by: c.inserted_at))

# Nested preload
Repo.preload(posts, [comments: [:author, :replies]])

Important: Use is_nil(field) not field == nil in queries. Only one select per query — use select_merge to compose.

Repo — Database Operations

# CRUD
Repo.insert(changeset)           # {:ok, struct} | {:error, changeset}
Repo.update(changeset)
Repo.delete(struct)
Repo.insert!(changeset)          # Returns struct or raises
Repo.get(Post, 1)                # nil if not found
Repo.get!(Post, 1)               # Raises if not found
Repo.get_by(Post, title: "Hello") # By arbitrary fields
Repo.one(query)                   # Raises if > 1 result

# Bulk operations — return {count, nil | results}
Repo.insert_all(Post, [%{title: "A"}, %{title: "B"}])
Repo.update_all(from(p in Post, where: p.old == true), set: [archived: true])
Repo.delete_all(from(p in Post, where: p.inserted_at < ago(1, "year")))
# NOTE: update_all does NOT update auto-generated fields like updated_at

# Upserts (on_conflict)
Repo.insert(changeset,
  on_conflict: :nothing,                         # Ignore conflict
  conflict_target: [:email]
)
Repo.insert(changeset,
  on_conflict: {:replace, [:name, :updated_at]}, # Update specific fields
  conflict_target: [:email]
)
Repo.insert(changeset,
  on_conflict: :replace_all,                     # Replace everything
  conflict_target: :id
)

# Aggregation
Repo.aggregate(Post, :count)                     # SELECT count(*)
Repo.aggregate(Post, :sum, :views)               # SELECT sum(views)
Repo.aggregate(query, :avg, :price)              # Works with queries too

Ecto.Multi — Atomic Transactions

Group operations that must all succeed or all fail:

alias Ecto.Multi

Multi.new()
|> Multi.insert(:post, Post.changeset(%Post{}, post_attrs))
|> Multi.insert(:comment, fn %{post: post} ->
     Comment.changeset(%Comment{}, %{post_id: post.id, body: "First!"})
   end)
|> Multi.update_all(:increment, from(u in User, where: u.id == ^user_id),
     inc: [post_count: 1])
|> Multi.run(:notify, fn _repo, %{post: post} ->
     # Arbitrary logic — return {:ok, _} or {:error, _}
     Notifications.send_new_post(post)
   end)
|> Repo.transaction()
# => {:ok, %{post: %Post{}, comment: %Comment{}, increment: {1, nil}, notify: :sent}}
# => {:error, failed_op, failed_value, changes_so_far}

Key patterns: Each operation name must be unique. Failed operations roll back everything. Use Multi.run/3 for arbitrary logic. Test with Multi.to_list/1 to inspect without executing.

Streaming — Large Result Sets

Repo.transaction(fn ->
  Post
  |> where([p], p.status == :published)
  |> Repo.stream(max_rows: 500)
  |> Stream.each(&process_post/1)
  |> Stream.run()
end)

Must execute within a transaction. Default batch size is 500 rows.

Migrations

defmodule MyApp.Repo.Migrations.CreateProducts do
  use Ecto.Migration

  def change do
    create table("products") do
      add :title, :string, null: false, size: 255
      add :price, :decimal, precision: 10, scale: 2
      add :status, :string, default: "draft"
      add :metadata, :map, default: %{}
      add :tags, {:array, :string}, default: []
      add :category_id, references("categories", on_delete: :restrict)
      timestamps(type: :utc_datetime)
    end

    create index("products", [:category_id])
    create unique_index("products", [:title])
    create index("products", [:status, :inserted_at])
    create index("products", [:tags], using: :gin)           # Postgres array index
    create index("products", [:title],
      where: "status = 'published'", name: :published_title_idx)  # Partial index
  end
end

Alter tables:

def change do
  alter table("products") do
    add :slug, :string
    modify :title, :text, from: :string     # :from required for reversibility
    remove :deprecated_field, :string       # type required for reversibility
  end

  # Raw SQL when needed
  execute "CREATE EXTENSION IF NOT EXISTS \"pg_trgm\"",
          "DROP EXTENSION IF EXISTS \"pg_trgm\""
end

Run migrations: mix ecto.migrate / mix ecto.rollback / mix ecto.reset

Ecto.Enum

# String storage (default)
field :status, Ecto.Enum, values: [:draft, :published, :archived]

# Integer storage
field :priority, Ecto.Enum, values: [low: 1, medium: 2, high: 3]

# Array of enums
field :roles, {:array, Ecto.Enum}, values: [:admin, :editor, :viewer]

# Query helpers
Ecto.Enum.values(Product, :status)       # [:draft, :published, :archived]
Ecto.Enum.dump_values(Product, :status)  # ["draft", "published", "archived"]
Ecto.Enum.mappings(Product, :status)     # [draft: "draft", ...] — for form dropdowns

Telemetry — Built-in Observability

Phoenix 1.8 includes a Telemetry supervisor that tracks request duration, Ecto query times, and VM metrics out of the box.

# lib/my_app_web/telemetry.ex (auto-generated)
defmodule MyAppWeb.Telemetry do
  use Supervisor
  import Telemetry.Metrics

  def metrics do
    [
      summary("phoenix.endpoint.stop.duration", unit: {:native, :millisecond}),
      summary("phoenix.router_dispatch.stop.duration", tags: [:route], unit: {:native, :millisecond}),
      summary("my_app.repo.query.total_time", unit: {:native, :millisecond}),
      summary("vm.memory.total", unit: {:byte, :kilobyte}),
      summary("vm.total_run_queue_lengths.total"),
      summary("vm.total_run_queue_lengths.cpu"),
    ]
  end
end

Integrates with PromEx for Prometheus/Grafana dashboards (see Part 4: Ecosystem).


Security Best Practices

Never pass untrusted input to: Code.eval_string/3, :os.cmd/2, System.cmd/3, System.shell/2, :erlang.binary_to_term/2

Ecto prevents SQL injection by default — the query DSL parameterizes all inputs. Only Ecto.Adapters.SQL.query/4 with raw string interpolation is vulnerable.

Safe deserialization:

# UNSAFE — even with :safe flag
:erlang.binary_to_term(user_input, [:safe])

# SAFE — prevents executable terms
Plug.Crypto.non_executable_binary_to_term(user_input, [:safe])

CSRF protection is built into the :browser pipeline via protect_from_forgery. Content Security Policy is set by put_secure_browser_headers.


Testing Phoenix

# Controller test
defmodule MyAppWeb.ProductControllerTest do
  use MyAppWeb.ConnCase

  test "GET /products", %{conn: conn} do
    conn = get(conn, ~p"/products")
    assert html_response(conn, 200) =~ "Products"
  end

  test "POST /products creates product", %{conn: conn} do
    conn = post(conn, ~p"/products", product: %{title: "Widget", price: 9.99})
    assert redirected_to(conn) =~ ~p"/products/"
  end
end

# LiveView test
defmodule MyAppWeb.CounterLiveTest do
  use MyAppWeb.ConnCase
  import Phoenix.LiveViewTest

  test "increments counter", %{conn: conn} do
    {:ok, view, html} = live(conn, ~p"/counter")
    assert html =~ "Count: 0"

    assert view
           |> element("button", "+1")
           |> render_click() =~ "Count: 1"
  end
end

# Channel test
defmodule MyAppWeb.RoomChannelTest do
  use MyAppWeb.ChannelCase

  test "broadcasts new messages" do
    {:ok, _, socket} = subscribe_and_join(socket(MyAppWeb.UserSocket), MyAppWeb.RoomChannel, "room:lobby")

    push(socket, "new_msg", %{"body" => "hello"})
    assert_broadcast "new_msg", %{body: "hello"}
  end
end

Generators Cheat Sheet

# HTML CRUD (controller + views + templates + context + schema + migration)
mix phx.gen.html Catalog Product products title:string price:decimal

# LiveView CRUD
mix phx.gen.live Catalog Product products title:string price:decimal

# JSON API
mix phx.gen.json Catalog Product products title:string price:decimal

# Context + schema only (no web layer)
mix phx.gen.context Catalog Product products title:string price:decimal

# Schema + migration only
mix phx.gen.schema Product products title:string price:decimal

# Authentication
mix phx.gen.auth Accounts User users

# Channel
mix phx.gen.channel Room

# Presence
mix phx.gen.presence

# Release files (Dockerfile, release.ex, overlay scripts)
mix phx.gen.release
mix phx.gen.release --docker  # Include Dockerfile

Release & Deployment

# Generate release infrastructure
mix phx.gen.release --docker

# Build release
MIX_ENV=prod mix deps.get --only prod
MIX_ENV=prod mix compile
MIX_ENV=prod mix assets.deploy
MIX_ENV=prod mix release

# Release commands
_build/prod/rel/my_app/bin/server     # Start with Phoenix server
_build/prod/rel/my_app/bin/migrate    # Run migrations
_build/prod/rel/my_app/bin/my_app remote  # Attach IEx console

Runtime config (config/runtime.exs):

import Config

if config_env() == :prod do
  database_url = System.fetch_env!("DATABASE_URL")
  secret_key_base = System.fetch_env!("SECRET_KEY_BASE")

  config :my_app, MyApp.Repo,
    url: database_url,
    pool_size: String.to_integer(System.get_env("POOL_SIZE") || "10")

  config :my_app, MyAppWeb.Endpoint,
    url: [host: System.fetch_env!("PHX_HOST"), port: 443, scheme: "https"],
    http: [ip: {0, 0, 0, 0}, port: String.to_integer(System.get_env("PORT") || "4000")],
    secret_key_base: secret_key_base
end