From elixir-phoenix-guide
Provides rules and code examples for Phoenix Channels: authenticate sockets in connect/3, authorize topics in join/3, handle messages with handle_in/push/broadcast, use Presence, keep channels thin.
npx claudepluginhub j-morgan6/elixir-phoenix-guide --plugin elixir-phoenix-guideThis skill uses the workspace's default tool permissions.
For non-LiveView real-time features: mobile clients, SPAs, external APIs, inter-service communication.
Searches, retrieves, and installs Agent Skills from prompts.chat registry using MCP tools like search_skills and get_skill. Activates for finding skills, browsing catalogs, or extending Claude.
Searches prompts.chat for AI prompt templates by keyword or category, retrieves by ID with variable handling, and improves prompts via AI. Use for discovering or enhancing prompts.
Checks Next.js compilation errors using a running Turbopack dev server after code edits. Fixes actionable issues before reporting complete. Replaces `next build`.
For non-LiveView real-time features: mobile clients, SPAs, external APIs, inter-service communication.
connect/3 — channels bypass the Plug pipeline; tokens must be verified in the socketjoin/3 — verify the user can access the requested topic before allowing the connectionhandle_in for client-to-server, push for server-to-client, broadcast for server-to-all — never confuse the direction{:reply, :ok, socket} or {:reply, {:error, reason}, socket} from handle_in — don't silently drop messagesChannels bypass the Plug pipeline, so session-based auth doesn't work. Use token-based authentication.
# In a controller or LiveView — generate a token for the current user
defmodule MyAppWeb.UserAuth do
def generate_socket_token(conn) do
Phoenix.Token.sign(conn, "user socket", conn.assigns.current_user.id)
end
end
# In your layout or root template
<script>
window.userToken = "<%= Phoenix.Token.sign(@conn, "user socket", @current_user.id) %>"
</script>
defmodule MyAppWeb.UserSocket do
use Phoenix.Socket
channel "room:*", MyAppWeb.RoomChannel
channel "notifications:*", MyAppWeb.NotificationChannel
@impl true
def connect(%{"token" => token}, socket, _connect_info) do
# Tokens expire after 2 weeks by default — configure max_age
case Phoenix.Token.verify(socket, "user socket", token, max_age: 1_209_600) do
{:ok, user_id} ->
{:ok, assign(socket, :user_id, user_id)}
{:error, _reason} ->
:error
end
end
def connect(_params, _socket, _connect_info), do: :error
@impl true
def id(socket), do: "users_socket:#{socket.assigns.user_id}"
end
Bad:
# No authentication — anyone can connect
def connect(_params, socket, _connect_info) do
{:ok, socket}
end
Verify in join/3 that the user is allowed to access the topic.
defmodule MyAppWeb.RoomChannel do
use MyAppWeb, :channel
@impl true
def join("room:" <> room_id, _payload, socket) do
user_id = socket.assigns.user_id
if Rooms.member?(room_id, user_id) do
{:ok, assign(socket, :room_id, room_id)}
else
{:error, %{reason: "unauthorized"}}
end
end
end
Bad:
# No authorization — any authenticated user can join any room
def join("room:" <> room_id, _payload, socket) do
{:ok, assign(socket, :room_id, room_id)}
end
Always reply so the client knows the result.
@impl true
def handle_in("new_msg", %{"body" => body}, socket) do
user_id = socket.assigns.user_id
room_id = socket.assigns.room_id
case Chat.create_message(room_id, user_id, body) do
{:ok, message} ->
broadcast!(socket, "new_msg", %{
id: message.id,
body: message.body,
user_id: message.user_id,
inserted_at: message.inserted_at
})
{:reply, :ok, socket}
{:error, changeset} ->
{:reply, {:error, %{errors: format_errors(changeset)}}, socket}
end
end
Bad:
# No reply — client doesn't know if message was received
def handle_in("new_msg", %{"body" => body}, socket) do
broadcast!(socket, "new_msg", %{body: body})
{:noreply, socket}
end
Send a message to a specific client, not everyone.
# Push to this specific client only
push(socket, "typing", %{user_id: other_user_id})
# Broadcast to all clients on the topic (including sender)
broadcast!(socket, "new_msg", payload)
# Broadcast to all clients except the sender
broadcast_from!(socket, "user_joined", %{user_id: user_id})
For messages from PubSub, timers, or other processes.
@impl true
def handle_info({:new_notification, notification}, socket) do
push(socket, "notification", %{
title: notification.title,
body: notification.body
})
{:noreply, socket}
end
# Resource-specific — one room
"room:42"
# User-scoped — all notifications for a user
"notifications:user_123"
# Collection-wide — all public updates
"updates:all"
# Subtopic — specific channel within a room
"room:42:typing"
Pattern match in join to extract IDs:
def join("room:" <> room_id, _payload, socket) do
# room_id is a string — parse if needed
room_id = String.to_integer(room_id)
# ...
end
Use Phoenix.Presence for tracking who is online. It handles distributed nodes automatically.
# lib/my_app_web/channels/presence.ex
defmodule MyAppWeb.Presence do
use Phoenix.Presence,
otp_app: :my_app,
pubsub_server: MyApp.PubSub
end
defmodule MyAppWeb.RoomChannel do
use MyAppWeb, :channel
alias MyAppWeb.Presence
@impl true
def join("room:" <> room_id, _payload, socket) do
send(self(), :after_join)
{:ok, assign(socket, :room_id, room_id)}
end
@impl true
def handle_info(:after_join, socket) do
# Track this user's presence
{:ok, _} = Presence.track(socket, socket.assigns.user_id, %{
online_at: inspect(System.system_time(:second)),
typing: false
})
# Send current presence state to the joining client
push(socket, "presence_state", Presence.list(socket))
{:noreply, socket}
end
end
@impl true
def handle_in("typing", %{"typing" => typing}, socket) do
Presence.update(socket, socket.assigns.user_id, fn meta ->
Map.put(meta, :typing, typing)
end)
{:reply, :ok, socket}
end
| Feature | Channels | LiveView | PubSub |
|---|---|---|---|
| Client | Any (mobile, SPA, IoT) | Browser only | Server-side only |
| Protocol | WebSocket + custom | WebSocket + HTML | Erlang messages |
| Rendering | Client renders | Server renders | No rendering |
| Use when | Non-browser clients, custom protocols | Browser UI with real-time | Inter-process communication |
Choose Channels when:
Choose LiveView when:
Choose PubSub when:
defmodule MyAppWeb.RoomChannelTest do
use MyAppWeb.ChannelCase
setup do
user = user_fixture()
room = room_fixture(members: [user])
token = Phoenix.Token.sign(MyAppWeb.Endpoint, "user socket", user.id)
{:ok, socket} = connect(MyAppWeb.UserSocket, %{"token" => token})
{:ok, _, socket} = subscribe_and_join(socket, "room:#{room.id}", %{})
%{socket: socket, user: user, room: room}
end
test "new_msg broadcasts to room", %{socket: socket} do
ref = push(socket, "new_msg", %{"body" => "hello"})
assert_reply ref, :ok
assert_broadcast "new_msg", %{body: "hello"}
end
test "new_msg with invalid data returns error", %{socket: socket} do
ref = push(socket, "new_msg", %{"body" => ""})
assert_reply ref, :error, %{errors: _}
end
test "unauthorized user cannot join room" do
other_user = user_fixture()
token = Phoenix.Token.sign(MyAppWeb.Endpoint, "user socket", other_user.id)
{:ok, socket} = connect(MyAppWeb.UserSocket, %{"token" => token})
assert {:error, %{reason: "unauthorized"}} =
subscribe_and_join(socket, "room:#{room.id}", %{})
end
test "presence is tracked on join", %{socket: socket, user: user} do
assert %{^(to_string(user.id)) => %{metas: [%{online_at: _}]}} =
MyAppWeb.Presence.list(socket)
end
end
See phoenix-pubsub-patterns skill for server-side PubSub patterns.
See phoenix-liveview-essentials skill for LiveView real-time patterns.
See testing-essentials skill for comprehensive testing patterns.