phx-create-live-resource
Create LiveView Resource
This skill generates a complete CRUD Phoenix LiveView resource following your project's conventions.
Workflow Overview
- Discovery - Analyze existing patterns, check if schema exists
- Schema & Context - Generate if needed using
mix phx.gen.schema - LiveView Modules - Create index, show, and form component
- Templates - Generate HEEx templates for each view
- Routes - Add LiveView routes to router.ex
- Optional Features - Add search, pagination, export, soft delete
- Verify - Run tests and precommit checks
Phase 1: Discovery
Analyze Project Conventions
Before generating code, analyze the existing project structure:
# Find existing LiveView modules
ls lib/**/live/*_live/
# Check component structure
grep -A 20 "defp " lib/**/components/core_components.ex | head -60
# Review route patterns
grep "live " lib/**/router.ex
Analyze for:
- Module naming conventions (e.g.,
UserLive.IndexvsUsersLive) - Form component patterns (stateful vs stateless)
- Template organization (separate files vs inline)
- Tailwind utility patterns used
- How associations are handled in existing forms
Check Schema Existence
Ask the user:
Does the [ResourceName] schema already exist, or should I create it?
If schema doesn't exist, ask for:
- Resource name (singular):
User,Product,Category - Context name:
Accounts,Catalog,Inventory - Field definitions:
name:string,email:string unique,age:integer
If schema exists, identify:
- Schema module path
- Context module and available functions
- Existing associations
- Changeset validations
Phase 2: Schema Generation (if needed)
Run the Phoenix generator:
mix phx.gen.schema Context ResourceName table_name field1:type field2:type
Examples:
mix phx.gen.schema Accounts User users name:string email:string
mix phx.gen.schema Catalog Product products name:string price:decimal sku:string:unique
mix phx.gen.schema Inventory StockItem stock_items quantity:integer product_id:references:products
Then run migrations:
mix ecto.migrate
Handle Associations
For associations, ask the user first:
For belongs_to:
Does [Resource] belong_to another resource? (e.g., User belongs_to Organization)
If yes, add the reference and generate a select dropdown in forms.
For has_many / many_to_many:
Does [Resource] have many or many_to_many associations?
If yes, set up nested form handling or multi-select inputs.
Phase 3: Generate LiveView Modules
File Structure
Create the following files in lib/app_web/live/[resource_name]_live/:
lib/app_web/live/user_live/
├── index.ex
├── show.ex
├── form_component.ex
├── index.html.heex
├── show.html.heex
└── form_component.html.heex
index.ex Template
defmodule AppWeb.[ResourceName]Live.Index do
use AppWeb, :live_view
alias App.[Context]
alias App.[Context].[ResourceName]
@impl true
def mount(_params, _session, socket) do
{:ok, stream(socket, :[resource_names], Context.list_[resource_names]())}
end
@impl true
def handle_params(params, _url, socket) do
{:noreply, apply_action(socket, socket.assigns.live_action, params)}
end
defp apply_action(socket, :edit, %{"id" => id}) do
[resource_name] = Context.get_[resource_name]!(id)
socket
|> assign(:page_title, "Edit [ResourceName]")
|> assign(:[resource_name], [resource_name])
end
defp apply_action(socket, :new, _params) do
socket
|> assign(:page_title, "New [ResourceName]")
|> assign(:[resource_name], %ResourceName{})
end
defp apply_action(socket, :index, _params) do
socket
|> assign(:page_title, "Listing [ResourceNames]")
|> assign(:[resource_name], nil)
end
@impl true
def handle_info({AppWeb.[ResourceName]Live.FormComponent, {:saved, [resource_name]}}, socket) do
{:noreply, stream_insert(socket, :[resource_names], [resource_name])}
end
@impl true
def handle_event("delete", %{"id" => id}, socket) do
[resource_name] = Context.get_[resource_name]!(id)
{:ok, _} = Context.delete_[resource_name]([resource_name])
{:noreply, stream_delete(socket, :[resource_names], [resource_name])}
end
end
show.ex Template
defmodule AppWeb.[ResourceName]Live.Show do
use AppWeb, :live_view
alias App.[Context]
alias App.[Context].[ResourceName]
@impl true
def mount(_params, _session, socket) do
{:ok, socket}
end
@impl true
def handle_params(%{"id" => id}, _, socket) do
{:noreply,
socket
|> assign(:page_title, page_title(socket.assigns.live_action))
|> assign(:[resource_name], Context.get_[resource_name]!(id))}
end
defp page_title(:show), do: "Show [ResourceName]"
defp page_title(:edit), do: "Edit [ResourceName]"
@impl true
def handle_event("delete", _params, socket) do
[resource_name] = socket.assigns.[resource_name]
{:ok, _} = Context.delete_[resource_name]([resource_name])
{:noreply,
socket
|> put_flash(:info, "[ResourceName] deleted successfully")
|> push_patch(to: socket.assigns.patch)
}
end
end
form_component.ex Template
defmodule AppWeb.[ResourceName]Live.FormComponent do
use AppWeb, :live_component
alias App.[Context]
alias App.[Context].[ResourceName]
@impl true
def render(assigns) do
~H"""
<div>
<header>
<h1 class="font-semibold text-lg"><%= @title %></h1>
</header>
<.form
:let={form}
for={@changeset}
id="[resource_name]-form"
phx-target={@myself}
phx-change="validate"
phx-submit="save"
>
<.error :if={@check_errors}>
Oops, something went wrong! Please check the errors below.
</.error>
<%= if @current_action == :new do %>
<.inputs_for :let={fc} field={form[:field_name]}>
<.input_field form={fc} field={:field} label="Field Label" type="text" />
</.inputs_for>
<% end %>
<:actions>
<.button phx-disable-with="Saving...">Save [ResourceName]</.button>
</:actions>
</.form>
</div>
"""
end
@impl true
def update(%{[resource_name]: [resource_name]} = assigns, socket) do
changeset = Context.change_[resource_name]([resource_name])
{:ok,
socket
|> assign(assigns)
|> assign_form(changeset)}
end
@impl true
def handle_event("validate", %{"[resource_name]" => [resource_name]_params}, socket) do
changeset =
socket.assigns.[resource_name]
|> Context.change_[resource_name]([resource_name]_params)
|> Map.put(:action, :validate)
{:noreply, assign_form(socket, changeset)}
end
def handle_event("save", %{"[resource_name]" => [resource_name]_params}, socket) do
save_[resource_name](socket, socket.assigns.action, [resource_name]_params)
end
defp save_[resource_name](socket, :edit, [resource_name]_params) do
case Context.update_[resource_name](socket.assigns.[resource_name], [resource_name]_params) do
{:ok, [resource_name]} ->
notify_parent({:saved, [resource_name]})
{:noreply,
socket
|> put_flash(:info, "[ResourceName] updated successfully")
|> push_patch(to: socket.assigns.patch)}
{:error, %Ecto.Changeset{} = changeset} ->
{:noreply, assign_form(socket, changeset)}
end
end
defp save_[resource_name](socket, :new, [resource_name]_params) do
case Context.create_[resource_name]([resource_name]_params) do
{:ok, [resource_name]} ->
notify_parent({:saved, [resource_name]})
{:noreply,
socket
|> put_flash(:info, "[ResourceName] created successfully")
|> push_patch(to: socket.assigns.patch)}
{:error, %Ecto.Changeset{} = changeset} ->
{:noreply, assign_form(socket, changeset)}
end
end
defp assign_form(socket, %Ecto.Changeset{} = changeset) do
assign(socket, :changeset, changeset)
end
defp notify_parent(msg), do: send(self(), {__MODULE__, msg})
end
Phase 4: Generate Templates
index.html.heex Template
<div>
<.header>
Listing [ResourceNames]
<:actions>
<.link patch={~p"/[resource_path]/new"}>
<.button>New [ResourceName]</.button>
</.link>
</:actions>
</.header>
<.table
id="[resource_names]"
rows={@streams.[resource_names]}
row_click={fn {[id], [resource_name]} -> JS.navigate(~p"/[resource_path]/#{[id]}") end}
>
<:col :let={{[id], [resource_name]}} label="Field 1">
{<[resource_name]>.field1}
</:col>
<:col :let={{[id], [resource_name]}} label="Field 2">
{<[resource_name]>.field2}
</:col>
<:col :let={{_id, [resource_name]}} label="" align="right">
<.link
patch={~p"/[resource_path]/#{[resource_name]}/edit"}
class="ml-2"
>
Edit
</.link>
<.link
href={~p"/[resource_path]/#{[resource_name]}"}
method="delete"
data-confirm="Are you sure?"
class="ml-2 text-red-600"
>
Delete
</.link>
</:col>
</.table>
</div>
show.html.heex Template
<div>
<.header>
<%= @page_title %>
<:actions>
<.link patch={~p"/[resource_path]/#{@[resource_name]}/show/edit"}>
<.button>Edit [ResourceName]</.button>
</.link>
</:actions>
</.header>
<.list>
<:list_item label="Field 1:">
{@[resource_name].field1}
</:list_item>
<:list_item label="Field 2:">
{@[resource_name].field2}
</:list_item>
</.list>
<.back navigate={~p"/[resource_path]"}>Back to [resource_names]</.back>
</div>
form_component.html.heex Template
<.form
:let={form}
for={@changeset}
id="[resource_name]-form"
phx-target={@myself}
phx-change="validate"
phx-submit="save"
class="space-y-4"
>
<.error :if={@check_errors}>
Please correct the errors below.
</.error>
<%= for field <- @form_fields do %>
<.input
field={form[field]}
type={field_type(field)}
label={field_label(field)}
/>
<% end %>
<:actions>
<.button phx-disable-with="Saving...">Save [ResourceName]</.button>
</:actions>
</.form>
Phase 5: Add Routes
Add to router.ex:
scope "/", AppWeb do
live "/[resource_path]", [ResourceName]Live.Index, :index
live "/[resource_path]/new", [ResourceName]Live.Index, :new
live "/[resource_path]/:id/edit", [ResourceName]Live.Index, :edit
live "/[resource_path]/:id", [ResourceName]Live.Show, :show
live "/[resource_path]/:id/show/edit", [ResourceName]Live.Show, :edit
end
Phase 6: Optional Features
Before proceeding, ask the user:
Which optional features would you like to add?
[ ] Search/Filter - Live search and column filtering
[ ] Pagination - Page through large datasets
[ ] Export - CSV/Excel export functionality
[ ] Soft Delete - Mark records as deleted instead of removing
Search/Filter Implementation
Add to index.ex:
@impl true
def handle_event("search", %{"search" => search_params}, socket) do
[resource_names] = Context.search_[resource_names](search_params)
{:noreply, stream(socket, :[resource_names], [resource_names], at: 0)}
end
Add search input to index.html.heex:
<form phx-change="search" class="mb-4">
<.input
type="text"
name="search"
placeholder="Search..."
value={@search}
/>
</form>
Pagination Implementation
Using Scrivener or cursor-based:
defp apply_action(socket, :index, %{"page" => page}) do
opts = [page: String.to_integer(page), page_size: 20]
page = Context.paginate_[resource_names](opts)
socket
|> assign(:page_title, "Listing [ResourceNames]")
|> assign(:page, page)
|> assign(:[resource_name], nil)
end
Export Implementation
@impl true
def handle_event("export", _params, socket) do
[resource_names] = Context.list_[resource_names]()
csv = App.Export.to_csv([resource_names])
{:noreply,
socket
|> put_flash(:info, "Export generated")
|> put_root_layout(false)
|> put_headers([{"content-disposition", "attachment; filename=[resource_names].csv"}])
|> send_resp(200, csv)}
end
Soft Delete Implementation
Add deleted_at field to schema:
def changeset([resource_name], attrs) do
[resource_name]
|> cast(attrs, [:field1, :field2, :deleted_at])
|> validate_required([:field1, :field2])
end
def soft_delete([resource_name]) do
[resource_name]
|> change(%{deleted_at: NaiveDateTime.utc_now()})
|> Repo.update()
end
Phase 7: Verify
Run verification commands:
# Compile the project
mix compile
# Run tests
mix test
# Run precommit if available
mix precommit
If errors occur, fix them before completing.
UI Convention Detection
Before generating templates, analyze:
- Component patterns - Check
core_components.exfor existing table, list, form patterns - Tailwind utilities - Note what classes are used (avoid DaisyUI if not present)
- Form structure - Inline forms vs separate components
- Layout patterns - Header usage, action placement
Apply detected patterns to generated code rather than using generic templates.
Association Handling
belongs_to
Add a select dropdown:
<.input
field={form[:organization_id]}
type="select"
label="Organization"
prompt="Select an organization"
options={@organizations}
/>
In update/2:
def update(%{[resource_name]: [resource_name]} = assigns, socket) do
organizations = App.Accounts.list_organizations() |> Enum.map(&{&1.name, &1.id})
changeset = Context.change_[resource_name]([resource_name])
{:ok,
socket
|> assign(assigns)
|> assign(:organizations, organizations)
|> assign_form(changeset)}
end
has_many / many_to_many
Use checkbox group or multi-select:
<.input
field={form[:category_ids]}
type="checkbox"
label="Categories"
options={@categories}
/>
Common Field Types
Map Ecto types to form inputs:
| Ecto Type | Input Type |
|---|---|
:string |
text |
:text |
textarea |
:integer |
number |
:float |
number (step="0.01") |
:decimal |
number (step="0.01") |
:boolean |
checkbox |
:date |
date |
:time |
time |
:naive_datetime |
datetime-local |
:utc_datetime |
datetime-local |
:id |
hidden |
:references |
select |
:array |
textarea or multi-select |
Checklist
Before marking complete, verify:
- Schema exists (or created)
- Context functions available
- LiveView modules created (index, show, form)
- Templates created (index, show, form)
- Routes added to router.ex
- Associations handled correctly
- Optional features implemented (if requested)
- Code compiles without errors
- Tests pass
Examples
Basic Resource
Create a LiveView resource for User with name:string, email:string
With Associations
Create a LiveView resource for Product with name:string, price:decimal, belongs_to Category
With Optional Features
Create a LiveView resource for Order with status:string, total:decimal, created_at:utc_datetime. Add pagination and export.