Skill v1.0.0
currentAutomated scan100/100version: "1.0.0" name: phoenix-channels-essentials description: MANDATORY for ALL Phoenix Channels work. Invoke before writing socket, channel, or Presence modules. file_patterns:
- "**/*_socket.ex"
- "**/*_channel.ex"
- "/channels//*.ex"
auto_suggest: true
Phoenix Channels Essentials
For non-LiveView real-time features: mobile clients, SPAs, external APIs, inter-service communication.
RULES — Follow these with no exceptions
- Always authenticate in `connect/3` — channels bypass the Plug pipeline; tokens must be verified in the socket
- Authorize in `join/3` — verify the user can access the requested topic before allowing the connection
- Use `handle_in` for client-to-server, `push` for server-to-client, `broadcast` for server-to-all — never confuse the direction
- Keep channel modules thin — delegate business logic to context modules; channels are the transport layer
- Use Presence for tracking connected users — don't roll your own presence tracking; Phoenix.Presence handles node distribution
- Return `{:reply, :ok, socket}` or `{:reply, {:error, reason}, socket}` from `handle_in` — don't silently drop messages
Socket Authentication
Channels bypass the Plug pipeline, so session-based auth doesn't work. Use token-based authentication.
Generating Tokens (Server Side)
# In a controller or LiveView — generate a token for the current userdefmodule MyAppWeb.UserAuth dodef generate_socket_token(conn) doPhoenix.Token.sign(conn, "user socket", conn.assigns.current_user.id)endend# In your layout or root template<script>window.userToken = "<%= Phoenix.Token.sign(@conn, "user socket", @current_user.id) %>"</script>
Verifying Tokens (Socket)
defmodule MyAppWeb.UserSocket douse Phoenix.Socketchannel "room:*", MyAppWeb.RoomChannelchannel "notifications:*", MyAppWeb.NotificationChannel@impl truedef connect(%{"token" => token}, socket, _connect_info) do# Tokens expire after 2 weeks by default — configure max_agecase 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} ->:errorendenddef connect(_params, _socket, _connect_info), do: :error@impl truedef id(socket), do: "users_socket:#{socket.assigns.user_id}"end
Bad:
# No authentication — anyone can connectdef connect(_params, socket, _connect_info) do{:ok, socket}end
Topic Authorization
Verify in join/3 that the user is allowed to access the topic.
defmodule MyAppWeb.RoomChannel douse MyAppWeb, :channel@impl truedef join("room:" <> room_id, _payload, socket) douser_id = socket.assigns.user_idif Rooms.member?(room_id, user_id) do{:ok, assign(socket, :room_id, room_id)}else{:error, %{reason: "unauthorized"}}endendend
Bad:
# No authorization — any authenticated user can join any roomdef join("room:" <> room_id, _payload, socket) do{:ok, assign(socket, :room_id, room_id)}end
Channel Message Patterns
Client-to-Server (handle_in)
Always reply so the client knows the result.
@impl truedef handle_in("new_msg", %{"body" => body}, socket) douser_id = socket.assigns.user_idroom_id = socket.assigns.room_idcase 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}endend
Bad:
# No reply — client doesn't know if message was receiveddef handle_in("new_msg", %{"body" => body}, socket) dobroadcast!(socket, "new_msg", %{body: body}){:noreply, socket}end
Server-to-Client (push)
Send a message to a specific client, not everyone.
# Push to this specific client onlypush(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 senderbroadcast_from!(socket, "user_joined", %{user_id: user_id})
External Messages (handle_info)
For messages from PubSub, timers, or other processes.
@impl truedef handle_info({:new_notification, notification}, socket) dopush(socket, "notification", %{title: notification.title,body: notification.body}){:noreply, socket}end
Topic Naming Conventions
# 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 neededroom_id = String.to_integer(room_id)# ...end
Presence Tracking
Use Phoenix.Presence for tracking who is online. It handles distributed nodes automatically.
Setup
# lib/my_app_web/channels/presence.exdefmodule MyAppWeb.Presence douse Phoenix.Presence,otp_app: :my_app,pubsub_server: MyApp.PubSubend
Tracking in a Channel
defmodule MyAppWeb.RoomChannel douse MyAppWeb, :channelalias MyAppWeb.Presence@impl truedef join("room:" <> room_id, _payload, socket) dosend(self(), :after_join){:ok, assign(socket, :room_id, room_id)}end@impl truedef 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 clientpush(socket, "presence_state", Presence.list(socket)){:noreply, socket}endend
Updating Presence Metadata
@impl truedef handle_in("typing", %{"typing" => typing}, socket) doPresence.update(socket, socket.assigns.user_id, fn meta ->Map.put(meta, :typing, typing)end){:reply, :ok, socket}end
When to Use Channels vs LiveView vs PubSub
| 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:
- Mobile apps need real-time features
- SPA frontend (React, Vue) needs WebSocket communication
- External services need bidirectional communication
- You need a custom binary protocol
Choose LiveView when:
- Browser-based UI with real-time updates
- Server-rendered HTML is acceptable
- You want to avoid writing JavaScript
Choose PubSub when:
- Server-side inter-process communication only
- LiveView components need to communicate
- Background jobs need to notify the web layer
Testing Channels
defmodule MyAppWeb.RoomChannelTest douse MyAppWeb.ChannelCasesetup douser = 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}endtest "new_msg broadcasts to room", %{socket: socket} doref = push(socket, "new_msg", %{"body" => "hello"})assert_reply ref, :okassert_broadcast "new_msg", %{body: "hello"}endtest "new_msg with invalid data returns error", %{socket: socket} doref = push(socket, "new_msg", %{"body" => ""})assert_reply ref, :error, %{errors: _}endtest "unauthorized user cannot join room" doother_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}", %{})endtest "presence is tracked on join", %{socket: socket, user: user} doassert %{^(to_string(user.id)) => %{metas: [%{online_at: _}]}} =MyAppWeb.Presence.list(socket)endend
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.