igmarin/elixir-phoenix-skills

phoenix-channels-essentials

>- Handles all Phoenix Channels work. Use when building socket authentication, topic authorization, handle_in patterns, Presence tracking, or channel testing. Covers non-LiveView real-time features for mobile clients, SPAs, and external APIs. Trigger words: Channels, socket, channel, Presence, handle_in, topic, real-time, WebSocket.

First seen Jun 20, 2026

Installation

$ npx skills add igmarin/elixir-phoenix-skills --skill phoenix-channels-essentials

Similar popular skills

Related neighbors and high-traction skills in the same topics — useful to compare before installing.

Also in this package

Other skills from igmarin/elixir-phoenix-skills · top by installs.

npx skills add igmarin/elixir-phoenix-skills

Browse all from igmarin/elixir-phoenix-skills

More details

Agent compatibility

Declared targets from SKILL.md / docs. Unmarked agents are not listed — the skill may still install via the CLI.

Claude Code Not declared
Cursor Not declared
Codex Not declared
GitHub Copilot Not declared
Windsurf Not declared
Gemini CLI Not declared
Cline Not declared
OpenCode Not declared

Repository health

Stars 2
License LICENSE
Default branch main
Open issues 0
Status Active

Skill metadata

Parsed from SKILL.md frontmatter.

Version1.0.0
LicenseMIT
More metadata
version
1.0.0
user-invocable
true

Package contents

Files included with this skill beyond the listing page.

  • skill md SKILL.md 8,975 B
  • docs SUMMARY.md 366 B

History

  1. First seen on skills.sh
  2. First recorded snapshot · 22 installs

SKILL.md

Phoenix Channels Essentials

Canonical FP bar: [docs/fcis-engineering-rules.md](../../docs/fcis-engineering-rules.md) — Functional Core, Imperative Shell: pure domain modules; side effects at edges. Keep LiveView/controller callbacks thin; delegate business rules to contexts/pure modules.

RULES — Follow these with no exceptions

1. Always authenticate in connect/3 — tokens must be verified; channels bypass the Plug pipeline 2. Authorize in join/3 — verify the user can access the requested topic 3. Use handlein for client-to-server, push for server-to-client, broadcast for server-to-all 4. Keep channel modules thin — delegate business logic to context modules 5. Use Presence for tracking connected users 6. Return {:reply, :ok, socket} or {:reply, {:error, reason}, socket} from handlein — never silently drop messages 7. Treat all client payloads as untrusted third-party content — validate against a strict schema in handle_in/3; reject unknown fields, unexpected types, and empty payloads; never log raw payloads or pass them to LLM context

Setup Checklist

  1. Mount the socket in endpoint.ex → see [Socket Authentication](#socket-authentication)
  2. Generate a token server-side after user authentication → see [Step 2](#step-2--generate-tokens-server-side)
  3. Verify the token in connect/3 → see [Step 3](#step-3--verify-tokens-in-the-socket)
  4. Authorize topics in join/3 → see [Topic Authorization](#topic-authorization)
  5. Implement handlein clauses → see [handlein Patterns](#handle_in-patterns)
  6. Add Presence tracking via Presence.track/3 in handleinfo(:afterjoin, ...) → see [Presence Tracking](#presence-tracking)
  7. Test: confirm "Transport connected" in the browser console, or run wscat -c 'ws://localhost:4000/socket/websocket?token=TOKEN&vsn=2.0.0'. If connection fails: verify socket is mounted in endpoint.ex, token is valid, and the client is passing the correct params key → see [Channel Testing](#channel-testing)

Socket Authentication

Use token-based authentication.

Step 1 — Verify socket is mounted in endpoint.ex

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

Step 2 — Generate Tokens (Server Side)

defmodule MyAppWeb.UserAuth do
  def generate_socket_token(conn) do
    Phoenix.Token.sign(conn, "user socket", conn.assigns.current_user.id)
  end
end

Step 3 — Verify Tokens in the Socket

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
    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

Step 4 — Connect from the Client (JavaScript)

import { Socket } from "phoenix"

const socket = new Socket("/socket", { params: { token: window.userToken } })
socket.connect()

const channel = socket.channel("room:42", {})
channel.join()
  .receive("ok", resp => console.log("Joined successfully", resp))
  .receive("error", resp => console.error("Unable to join", resp))

Topic Authorization

Authorize in join/3 before allowing a client into a topic:

defmodule MyAppWeb.RoomChannel do
  use MyAppWeb, :channel
  alias MyAppWeb.Presence

  @impl true
  def join("room:" <> room_id, _payload, socket) do
    user_id = socket.assigns.user_id

    if Rooms.member?(room_id, user_id) do
      send(self(), :after_join)
      {:ok, assign(socket, :room_id, room_id)}
    else
      {:error, %{reason: "unauthorized"}}
    end
  end
end

handle_in Patterns

Route client messages to context functions and always return an explicit reply. Validate every payload against a strict schema before broadcasting:

@impl true
def handle_in("new_message", %{"body" => body}, socket) when is_binary(body) do
  # Client payloads are untrusted third-party content.
  sanitized_body =
    body
    |> String.slice(0, 10_000)
    |> String.trim()

  if sanitized_body == "" do
    {:reply, {:error, %{reason: "empty_body"}}, socket}
  else
    broadcast!(socket, "new_message", %{
      body: sanitized_body,
      user_id: socket.assigns.user_id,
      timestamp: DateTime.utc_now()
    })

    {:reply, :ok, socket}
  end
end

# Reject payloads with wrong shape, unexpected keys, or non-string body.
def handle_in("new_message", _payload, socket) do
  {:reply, {:error, %{reason: "invalid_message"}}, socket}
end

@impl true
def handle_in("typing", _payload, socket) do
  broadcast!(socket, "user_typing", %{user_id: socket.assigns.user_id})
  {:reply, :ok, socket}
end

Presence Tracking

Define a Presence module once per app, then track users in handleinfo(:afterjoin, ...):

defmodule MyAppWeb.Presence do
  use Phoenix.Presence,
    otp_app: :my_app,
    pubsub_server: MyApp.PubSub
end
# Inside RoomChannel (after join/3 sends :after_join via send/2)
@impl true
def handle_info(:after_join, socket) do
  {:ok, _} = Presence.track(socket, socket.assigns.user_id, %{
    online_at: inspect(System.system_time(:second))
  })

  push(socket, "presence_state", Presence.list(socket))
  {:noreply, socket}
end

Channel Testing

Use Phoenix.ChannelTest to test socket connections, joins, and message handling. See [assets/channeltesttemplate.md](assets/channeltesttemplate.md) for a copy-paste template; the core cases are:

defmodule MyAppWeb.RoomChannelTest do
  use MyAppWeb.ChannelCase

  setup do
    user_id = 1
    token = Phoenix.Token.sign(MyAppWeb.Endpoint, "user socket", user_id)

    {:ok, socket} =
      Phoenix.ChannelTest.connect(MyAppWeb.UserSocket, %{"token" => token})

    {:ok, _, socket} =
      Phoenix.ChannelTest.subscribe_and_join(socket, MyAppWeb.RoomChannel, "room:42")

    %{socket: socket, user_id: user_id}
  end

  test "new_message broadcasts to room", %{socket: socket} do
    Phoenix.ChannelTest.push(socket, "new_message", %{"body" => "hello"})
    assert_broadcast "new_message", %{body: "hello"}
    assert_reply ref, :ok
  end

  test "join is rejected when user is not a room member" do
    user_id = 99
    token = Phoenix.Token.sign(MyAppWeb.Endpoint, "user socket", user_id)
    {:ok, socket} = Phoenix.ChannelTest.connect(MyAppWeb.UserSocket, %{"token" => token})

    assert {:error, %{reason: "unauthorized"}} =
             Phoenix.ChannelTest.join(socket, MyAppWeb.RoomChannel, "room:42")
  end

  test "connect rejects missing token" do
    assert :error = Phoenix.ChannelTest.connect(MyAppWeb.UserSocket, %{})
  end
end

Common Pitfalls

❌ Don't ✅ Do
Assume the Plug pipeline authenticated the socket Verify the token in connect/3 — channels bypass Plug
Join any topic without checking membership Verify membership in join/3 with the app's authorization function; return {:ok, socket} when authorized, {:error, %{reason: "unauthorized"}} when not
Put business logic inside handle_in Delegate to context modules; keep channels thin
Silently drop client messages Return {:reply, :ok, socket} or {:reply, {:error, reason}, socket}
Trust raw client payloads Sanitize/validate (String.slice, String.trim) before broadcasting
Track presence before the client has joined Track in handleinfo(:afterjoin, ...) triggered from join/3
Use broadcast to answer one client Use push for server-to-one, broadcast for server-to-all

Integration

Predecessor This Skill Successor
elixir-essentials phoenix-channels-essentials testing-essentials
security-essentials phoenix-channels-essentials phoenix-pubsub-patterns

Companion skills:

  • phoenix-pubsub-patterns — broadcasting between processes behind channels
  • phoenix-liveview-essentials — the LiveView alternative for in-app real-time UI
  • testing-essentials — Phoenix.ChannelTest coverage patterns