Writing a Blog Engine in Phoenix and Elixir: Part 9, live comments

Brandon Richey
16 min readMar 14, 2016

--

Latest Update: 08/02/2016

Tilt photography on books and pamphlets makes the letters look so lifelike!

Previous Post in this series

Writing A Blog Engine in Phoenix and Elixir: Part 8, Finishing Comments

Current Versions

  • Elixir: v1.3.1
  • Phoenix: v1.2.0
  • Ecto: v2.0.2

Where We Left Off

In the last post, we finished making the comments section feature-complete! Now that the functionality is all there, let’s make the functionality cool using the features that Elixir and Phoenix give us out of the box. Let’s make the commenting system a live commenting system via Phoenix’s channels feature! Fair warning: this tutorial is VERY javascript-heavy!

We’ll do the same thing we’ve done the last few tutorials: let’s design our feature before implementing our feature. Our live commenting system should have a few requirements:

  1. New comments should be broadcast only to authorized users as they’re added
  2. Approved comments should be broadcast to all users as they’re approved
  3. Deleted comments should be removed from all users

Starting Off With Our Channels

The first step in any channel implementation in Phoenix is to start digging into web/channels/user_socket.ex. We’ll modify the commented line at the top underneath ## Channels to the following:

channel "comments:*", Pxblog.CommentChannel

And next we’ll create the actual channel we’ll be working with. We’ll take advantage of a Phoenix generator for this:

$ mix phoenix.gen.channel Comment
* creating web/channels/comment_channel.ex
* creating test/channels/comment_channel_test.exs
Add the channel to your `web/channels/user_socket.ex` handler, for example:channel "comment:lobby", Pxblog.CommentChannel

We’ll actually define a few separate channels defining authorization levels. Remember our feature specs that we wrote at the top. Based on those requirements, I see the channel design as:

  1. A comments channel per each post_id

We’ll start with the simplest implementation and work backwards to implement security, so we’ll just start off with an authorized channel that everyone can see (for now). We also need to define the events that need to be broadcasted for:

Authorized:

  1. Comment Created
  2. Comment Deleted
  3. Comment Approved

Public:

  1. Comment Approved
  2. Comment Deleted

Again, we’ll start with our Authorized channel. But first, we need to get some base stuff set up. Let’s add jquery to our application to make it a little easier for us to manipulate the DOM.

Adding jQuery To Our Project Through Brunch

Let’s start by installing jquery via NPM.

npm install --save-dev jquery

And then restart your Phoenix server. Next, we’ll very quickly verify that we’ve installed jquery successfully! Open up web/static/js/app.js and add the following code at the bottom:

import $ from "jquery"if ($("body")) {
console.log("jquery works!")
}

Assuming you get the “jquery works!” message in your developer console, you can delete those lines above and we can move on to the next part of our implementation.

Implementing Our Channels In JavaScript

First, back in web/static/js/app.js we’ll uncomment the import statement for socket.

import socket from "./socket"

Next, we’ll open up web/static/js/socket.js and make a few minor modifications:

// For right now, just hardcode this to whatever post id you're working withconst postId = 2;
const channel = socket.channel(`comments:${postId}`, {});
channel.join()
.receive("ok", resp => { console.log("Joined successfully", resp) })
.receive("error", resp => { console.log("Unable to join", resp) });

Next, we’ll need to reference our socket design to figure out the messages we should be listening for/broadcasting. We’ll use “CREATED_COMMENT” for newly-created comments, “APPROVED_COMMENT” for approved comments, and “DELETED_COMMENT” for comments that are deleted. Let’s add those as constants to socket.js:

const CREATED_COMMENT  = "CREATED_COMMENT"
const APPROVED_COMMENT = "APPROVED_COMMENT"
const DELETED_COMMENT = "DELETED_COMMENT"

Next, we’re going to add our event handlers for each of those actions for our channel.

channel.on(CREATED_COMMENT, (payload) => {
console.log("Created comment", payload)
});
channel.on(APPROVED_COMMENT, (payload) => {
console.log("Approved comment", payload)
});
channel.on(DELETED_COMMENT, (payload) => {
console.log("Deleted comment", payload)
});

And finally, we’ll modify our submit button to instead fire off a fake event:

$("input[type=submit]").on("click", (event) => {
event.preventDefault()
channel.push(CREATED_COMMENT, { author: "test", body: "body" })
})

Tweaking Our Elixir Code To Support Channels

Now, if we test this in our browser right now, this will fail! You will likely get a message similar to:

[error] GenServer #PID<0.1250.0> terminating
** (FunctionClauseError) no function clause matching in Pxblog.CommentChannel.handle_in/3
(pxblog) web/channels/comment_channel.ex:14: Pxblog.CommentChannel.handle_in(“CREATED_COMMENT”, %{“author” => “test”, “body” => “body”}, %Phoenix.Socket{assigns: %{}, channel: Pxblog.CommentChannel, channel_pid: #PID<0.1250.0>, endpoint: Pxblog.Endpoint, handler: Pxblog.UserSocket, id: nil, joined: true, pubsub_server: Pxblog.PubSub, ref: “2”, serializer: Phoenix.Transports.WebSocketSerializer, topic: “comments:2”, transport: Phoenix.Transports.WebSocket, transport_name: :websocket, transport_pid: #PID<0.1247.0>})
(phoenix) lib/phoenix/channel/server.ex:229: Phoenix.Channel.Server.handle_info/2
(stdlib) gen_server.erl:615: :gen_server.try_dispatch/4
(stdlib) gen_server.erl:681: :gen_server.handle_msg/5
(stdlib) proc_lib.erl:240: :proc_lib.init_p_do_apply/3
Last message: %Phoenix.Socket.Message{event: “CREATED_COMMENT”, payload: %{“author” => “test”, “body” => “body”}, ref: “2”, topic: “comments:2”}
State: %Phoenix.Socket{assigns: %{}, channel: Pxblog.CommentChannel, channel_pid: #PID<0.1250.0>, endpoint: Pxblog.Endpoint, handler: Pxblog.UserSocket, id: nil, joined: true, pubsub_server: Pxblog.PubSub, ref: nil, serializer: Phoenix.Transports.WebSocketSerializer, topic: “comments:2”, transport: Phoenix.Transports.WebSocket, transport_name: :websocket, transport_pid: #PID<0.1247.0>}

Right now, we haven’t actually defined any function to handle this in our channel. Open up web/channels/comment_channel.ex and let’s change the handle_in function for the “shout” message to instead watch for, and broadcast back out, “CREATED_COMMENT”. We also need to modify the default join function at the top:

def join("comments:" <> _comment_id, payload, socket) do
if authorized?(payload) do
{:ok, socket}
else
{:error, %{reason: "unauthorized"}}
end
end
# ...# It is also common to receive messages from the client and
# broadcast to everyone in the current topic (comments:lobby).
def handle_in("CREATED_COMMENT", payload, socket) do
broadcast socket, "CREATED_COMMENT", payload
{:noreply, socket}
end

Since we’re here, let’s also add the code for the other two messages we’re expecting to listen to.

def handle_in("APPROVED_COMMENT", payload, socket) do
broadcast socket, "APPROVED_COMMENT", payload
{:noreply, socket}
end

def handle_in("DELETED_COMMENT", payload, socket) do
broadcast socket, "DELETED_COMMENT", payload
{:noreply, socket}
end

We need to make some modifications to our templates, too. We need to know which post we’re working with and who the current user is, so at the top of web/templates/post/show.html.eex, add the following code:

<input type="hidden" id="post-id" value="<%= @post.id %>">

Now open up web/templates/comment/comment.html.eex and change the opening div:

<div id="comment-<%= @comment.id %>" class="comment" data-comment-id="<%= @comment.id %>">

Since we’re going to make everything comment-related handled via JavaScript, we actually need to remove a bunch of the code we wrote earlier with our approve/reject buttons. We’ll change that whole block to instead look like this:

<%= if @conn.assigns[:author_or_admin] do %>
<%= unless @comment.approved do %>
<button class="btn btn-xs btn-primary approve">Approve</button>
<% end %>
<button class="btn btn-xs btn-danger delete">Delete</button>
<% end %>

Also, where we display the comment’s author and the comment’s body, modify the strong tag in the author and the div tag in the body to have a class of .comment-author and .comment-body, respectively.

<div class="col-xs-4">
<strong class="comment-author"><%= @comment.author %></strong>
</div>

...

<div class="col-xs-12 comment-body">
<%= @comment.body %>
</div>

Finally, we need to make sure we can address the comment author and comment body inputs appropriately, so open up web/templates/comment/form.html.eex and make sure that the text area for comment body and the submit button for creating comments looks like this:

<div class="form-group">
<%= label f, :body, class: "control-label" %>
<%= textarea f, :body, class: "form-control" %>
<%= error_tag f, :body %>
</div>
<div class="form-group">
<%= submit "Submit", class: "btn btn-primary create-comment" %>
</div>

Now we should be able to implement each of our broadcast options appropriately, so let’s head back to javascript land and continue our implementation!

Implementing A User Id Token With Phoenix.Token

We will also need an implementation that will provide a way to verify the current user is who they say they are and has access to modifying comment data. To do this, we’ll utilize Phoenix’s built-in Phoenix.Token module!

We’ll start off by assigning our user token in our application layout, since it’s useful enough that we’ll probably want to display it everywhere. In web/templates/layout/app.html.eex, add the following underneath the other meta tags:

<%= if user = current_user(@conn) do %>
<%= tag :meta, name: "channel_token", content: Phoenix.Token.sign(@conn, "user", user.id) %>
<% end %>

What we’re doing here is we’re telling Phoenix that we want a signed token that specifies the verified user’s id (if the user is logged in, of course!). This gives us a nice javascript-friendly way to verify the user’s user_id without having to trust a hidden input or something wacky on the page!

Next, in web/static/js/socket.js, we’ll make a few modifications to our socket connection code:

// Grab the user's token from the meta tag
const userToken = $("meta[name='channel_token']").attr("content")
// And make sure we're connecting with the user's token to persist the user id to the session
const socket = new Socket("/socket", {params: {token: userToken}})
// And then connect to our socket
socket.connect()

Now we’ll pass a valid token back to the Phoenix code! Which means we have to jump to another source file. This time, we want web/channels/user_socket.ex, where we’ll modify our connect function to verify the user’s token:

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

So we call Phoenix.Token’s verify function, pass it the socket, the value we’re verifying from the token, the token itself, and a max_age value (the maximum we want to allow a token to exist for. We’ll say two weeks here).

If the verification is successful, it will send back a tuple of {:ok, [value that was pulled from the token]}, which in our case is the user_id. We then okay the connection with the user_id stored in the socket (sort of like storing the value in the session or conn).

If the verification fails, that’s okay, since we still want users not logged in to be able to see the live updates, they just won’t have a verified user id to do anything with, so we just return out {:ok, socket} with no assigns!

Returning To Socket.js

We have an absolute ton of javascript we need to support this, so let’s diagram it out a little bit. We need to:

  1. Modify the postId const to instead pull the value from the DOM
  2. Create a function that can give us a template for a new comment
  3. Create a function that will return the comment author
  4. Create a function that will return the comment body
  5. Create a function that will return the comment id
  6. Create a function that will reset the comment author/body
  7. Create a function that will handle creating a comment
  8. Create a function that will handle approving a comment
  9. Create a function that will handle deleting a comment
  10. Create a function that will handle receiving a created comment event
  11. Create a function that will handle receiving an approved comment event
  12. Create a function that will handle receiving a deleted comment event

I warned you that this tutorial would be pretty javascript-heavy :). Let’s waste no time and jump right into writing each of these functions. The comments provide the descriptions for each operation; you can match each requirement listed above by searched for REQ N, where N is the number of the step listed above:

// Import the socket library
import {Socket} from "phoenix"
// And import jquery for DOM manipulation
import $ from "jquery"

// Grab the user's token from the meta tag
const userToken = $("meta[name='channel_token']").attr("content")
// And make sure we're connecting with the user's token to persist the user id to the session
const socket = new Socket("/socket", {params: {token: userToken}})
// And connect out
socket.connect()

// Our actions to listen for
const CREATED_COMMENT = "CREATED_COMMENT"
const APPROVED_COMMENT = "APPROVED_COMMENT"
const DELETED_COMMENT = "DELETED_COMMENT"

// REQ 1: Grab the current post's id from a hidden input on the page
const postId = $("#post-id").val()
const channel = socket.channel(`comments:${postId}`, {})
channel.join()
.receive("ok", resp => { console.log("Joined successfully", resp) })
.receive("error", resp => { console.log("Unable to join", resp) })

// REQ 2: Based on a payload, return to us an HTML template for a comment
// Consider this a poor version of JSX
const createComment = (payload) => `
<div id="comment-${payload.commentId}" class="comment" data-comment-id="${payload.commentId}">
<div class="row">
<div class="col-xs-4">
<strong class="comment-author">${payload.author}</strong>
</div>
<div class="col-xs-4">
<em>${payload.insertedAt}</em>
</div>
<div class="col-xs-4 text-right">
${ userToken ? '<button class="btn btn-xs btn-primary approve">Approve</button> <button class="btn btn-xs btn-danger delete">Delete</button>' : '' }
</div>
</div>
<div class="row">
<div class="col-xs-12 comment-body">
${payload.body}
</div>
</div>
</div>
`
// REQ 3: Provide the comment's author from the form
const getCommentAuthor = () => $("#comment_author").val()
// REQ 4: Provide the comment's body from the form
const getCommentBody = () => $("#comment_body").val()
// REQ 5: Based on something being clicked, find the parent comment id
const getTargetCommentId = (target) => $(target).parents(".comment").data("comment-id")
// REQ 6: Reset the input fields to blank
const resetFields = () => {
$("#comment_author").val("")
$("#comment_body").val("")
}

// REQ 7: Push the CREATED_COMMENT event to the socket with the appropriate author/body
$(".create-comment").on("click", (event) => {
event.preventDefault()
channel.push(CREATED_COMMENT, { author: getCommentAuthor(), body: getCommentBody(), postId })
resetFields()
})

// REQ 8: Push the APPROVED_COMMENT event to the socket with the appropriate author/body/comment id
$(".comments").on("click", ".approve", (event) => {
event.preventDefault()
const commentId = getTargetCommentId(event.currentTarget)
// Pull the approved comment author
const author = $(`#comment-${commentId} .comment-author`).text().trim()
// Pull the approved comment body
const body = $(`#comment-${commentId} .comment-body`).text().trim()
channel.push(APPROVED_COMMENT, { author, body, commentId, postId })
})

// REQ 9: Push the DELETED_COMMENT event to the socket but only pass the comment id (that's all we need)
$(".comments").on("click", ".delete", (event) => {
event.preventDefault()
const commentId = getTargetCommentId(event.currentTarget)
channel.push(DELETED_COMMENT, { commentId, postId })
})

// REQ 10: Handle receiving the CREATED_COMMENT event
channel.on(CREATED_COMMENT, (payload) => {
// Don't append the comment if it hasn't been approved
if (!userToken && !payload.approved) { return; }
// Add it to the DOM using our handy template function
$(".comments h2").after(
createComment(payload)
)
})

// REQ 11: Handle receiving the APPROVED_COMMENT event
channel.on(APPROVED_COMMENT, (payload) => {
// If we don't already have the right comment, then add it to the DOM
if ($(`#comment-${payload.commentId}`).length === 0) {
$(".comments h2").after(
createComment(payload)
)
}
// And then remove the "Approve" button since we know it has been approved
$(`#comment-${payload.commentId} .approve`).remove()
})

// REQ 12: Handle receiving the DELETED_COMMENT event
channel.on(DELETED_COMMENT, (payload) => {
// Just delete the comment from the DOM
$(`#comment-${payload.commentId}`).remove()
})

export default socket

That should be enough javascript for now. Now we have the functionality working, although without any real security behind it. Let’s flesh it out more back in our Elixir code by creating a helper function for adding, approving, and deleting comments.

Writing Our Comment Channel Helper

All of our JavaScript code is golden and working, probably, so now we need to implement the code that will handle this on the backend. We’re going to start by creating a new helper module that will be the work horse for the database interactions required for creating/approving/deleting comments, so let’s create web/channels/comment_helper.ex (for sake of example, I’m going to post the whole module here first and then we’ll explain function by function):

defmodule Pxblog.CommentHelper do
alias Pxblog.Comment
alias Pxblog.Post
alias Pxblog.User
alias Pxblog.Repo

import Ecto, only: [build_assoc: 2]

def create(%{"postId" => post_id, "body" => body, "author" => author}, _socket) do
post = get_post(post_id)
changeset = post
|> build_assoc(:comments)
|> Comment.changeset(%{body: body, author: author})

Repo.insert(changeset)
end

def approve(%{"postId" => post_id, "commentId" => comment_id}, %{assigns: %{user: user_id}}) do
authorize_and_perform(post_id, user_id, fn ->
comment = Repo.get!(Comment, comment_id)
changeset = Comment.changeset(comment, %{approved: true})
Repo.update(changeset)
end)
end

def delete(%{"postId" => post_id, "commentId" => comment_id}, %{assigns: %{user: user_id}}) do
authorize_and_perform(post_id, user_id, fn ->
comment = Repo.get!(Comment, comment_id)
Repo.delete(comment)
end)
end

defp authorize_and_perform(post_id, user_id, action) do
post = get_post(post_id)
user = get_user(user_id)
if is_authorized_user?(user, post) do
action.()
else
{:error, "User is not authorized"}
end
end

defp get_user(user_id) do
Repo.get!(User, user_id)
end

defp get_post(post_id) do
Repo.get!(Post, post_id) |> Repo.preload([:user, :comments])
end

defp is_authorized_user?(user, post) do
(user && (user.id == post.user_id || Pxblog.RoleChecker.is_admin?(user)))
end
end

We’ll start at the top. We’ll be calling out to all of our Comment/Post/User/Repo modules a great deal, so alias them all at the top to keep our code a little cleaner. We will also need to import Ecto so that we can use the build_assoc function, but we only need the build_assoc with an arity of 2, so we’ll only import that function.

Next, we’ll jump right into creating a post. We are going to get into a habit of passing in the socket but we don’t always need it (such as in this case, since anyone can create comments). We pattern match out the post_id, body, and author values in our function’s arguments, and then we get into the actual code.

def create(%{"postId" => post_id, "body" => body, "author" => author}, _socket) do
post = get_post(post_id)
changeset = post
|> build_assoc(:comments)
|> Comment.changeset(%{body: body, author: author})

Repo.insert(changeset)
end

We fetch the post via the get_post function (which we haven’t written yet; it will be a private function at the bottom of our module). We then build a changeset off the post to build an associated comment with the body and author we supplied. We then return out the return of the Repo.insert function, and that’s it! This is pretty simple and standard Ecto code, so nothing should be that surprising (and the same will be true for nearly every other helper function we write here). Next, let’s look at approve:

def approve(%{"postId" => post_id, "commentId" => comment_id}, %{assigns: %{user: user_id}}) do
authorize_and_perform(post_id, user_id, fn ->
comment = Repo.get!(Comment, comment_id)
changeset = Comment.changeset(comment, %{approved: true})
Repo.update(changeset)
end)
end

Again, we pattern match out the values that we need (post_id and comment_id), and we also pattern match out the verified user id from the socket (the second argument). Next, we call a helper function called authorize_and_perform (I’ll talk about this next), and pass it an anonymous function that fetches the comment, updates the approved to true via a changeset, and then issues an update to the Repo. Pretty boilerplate code, but that authorize_and_perform seems neat and mysterious, so let’s digress to pull that code apart:

defp authorize_and_perform(post_id, user_id, action) do
post = get_post(post_id)
user = get_user(user_id)
if is_authorized_user?(user, post) do
action.()
else
{:error, "User is not authorized"}
end
end

So this is passed a post id and a user id, since both are required for proper authorization of actions to a comment. We then call another helper function we’ll write called is_authorized_user? which just takes the user and the post and returns true or false. Next, if all is good, we’ll call the anonymous function that we were passed in (notice the dot in between action and the parentheses. This is required due to this being an anonymous function!) Otherwise we’ll return out a tuple of {:error, “User is not authorized”} that we could catch later if we wanted to output some nice error message.

Basically, we’re wrapping authorization inside of a block that performs the auth and then performs the action specified with a fn -> … end block. This is a nice pattern when you’re duplicating a lot of logic!

Now that we’re comfortable with our authorize_and_perform function, let’s look at the call to delete:

def delete(%{"postId" => post_id, "commentId" => comment_id}, %{assigns: %{user: user_id}}) do
authorize_and_perform(post_id, user_id, fn ->
comment = Repo.get!(Comment, comment_id)
Repo.delete(comment)
end)
end

Same patterns here. Pull out the values we need via pattern matching, authorize the action, and then grab the comment and delete it! Simple!

Finally, we’ll look at our smaller helper functions.

defp get_user(user_id) do
Repo.get!(User, user_id)
end

defp get_post(post_id) do
Repo.get!(Post, post_id) |> Repo.preload([:user, :comments])
end

defp is_authorized_user?(user, post) do
(user && (user.id == post.user_id || Pxblog.RoleChecker.is_admin?(user)))
end

Fetch the user, fetch the post, and check if the user is authorized (code that we hijacked from our PostController). Now, our helper is complete, so let’s integrate that into our CommentChannel!

Integrating Comment Helper Into Comment Channel

All we need to do now is replace the initial code we had created with the CREATED/APPROVED/DELETED comment messages and have them use the helpers instead. Open up web/channels/comment_channel.ex:

 alias Pxblog.CommentHelper  # It is also common to receive messages from the client and
# broadcast to everyone in the current topic (comments:lobby).
def handle_in("CREATED_COMMENT", payload, socket) do
case CommentHelper.create(payload, socket) do
{:ok, comment} ->
broadcast socket, "CREATED_COMMENT", Map.merge(payload, %{insertedAt: comment.inserted_at, commentId: comment.id, approved: comment.approved})
{:noreply, socket}
{:error, _} ->
{:noreply, socket}
end
end

def handle_in("APPROVED_COMMENT", payload, socket) do
case CommentHelper.approve(payload, socket) do
{:ok, comment} ->
broadcast socket, "APPROVED_COMMENT", Map.merge(payload, %{insertedAt: comment.inserted_at, commentId: comment.id})
{:noreply, socket}
{:error, _} ->
{:noreply, socket}
end
end

def handle_in("DELETED_COMMENT", payload, socket) do
case CommentHelper.delete(payload, socket) do
{:ok, _} ->
broadcast socket, "DELETED_COMMENT", payload
{:noreply, socket}
{:error, _} ->
{:noreply, socket}
end
end

The patterns are the same in all three calls, so I’ll just walk through create:

# It is also common to receive messages from the client and
# broadcast to everyone in the current topic (comments:lobby).
def handle_in("CREATED_COMMENT", payload, socket) do
case CommentHelper.create(payload, socket) do
{:ok, comment} ->
broadcast socket, "CREATED_COMMENT", Map.merge(payload, %{insertedAt: comment.inserted_at, commentId: comment.id, approved: comment.approved})
{:noreply, socket}
{:error, _} ->
{:noreply, socket}
end
end

Our function signature didn’t change, so we’ll leave that alone. The first thing we do is set up a case statement with CommentHelper.create and pass it the payload and the socket (remember the pattern matching we just did). We either return out whatever repo returns (either {:ok, result} or {:error, something}) so we’ll pattern-match based on those (remember in our authorization helper that we return out {:error, error_message}, so the pattern matches will work here too). If we get an :ok with the created comment, we’ll broadcast out to the socket a CREATED_COMMENT message, and we’ll merge in some details that the database will provide us that the javascript didn’t have (such as the comment id and when the comment was inserted). If it failed, we won’t broadcast anything and we’ll return out the socket and carry-on with our merry ways.

Our end-result (except way more alive and interactive!)

Conclusion

We now have an awesome realtime commenting system for our blog and we’ve dived very heavily into the functionality behind Phoenix Channels and Tokens. We’ve also seen how the two interact to produce a better level of security! This example is not 100% perfect in the security sense; we still broadcast out every comment created, but we don’t add all of them to the div. If someone wanted they could still view all comments being added, even unapproved ones. We could further refine this by creating separate authenticated and non-authenticated channels and only broadcasting as appropriate on each one if we wanted to make it perfectly secure. Given that comment filtering just helps avoid spam, this was less important for this example but likely would be if you were writing something more secure. We’re also missing tests for everything, which I usually like to avoid but this tutorial is already a little long! In part 10, we’ll finish up writing up our tests to make sure our test coverage remains good, and we’ll also remove some of our old unnecessary code (since all of our comment logic is handled via channels, we can actually get rid of all of our previously-written Phoenix code for the CommentController.)

Our design is also still totally garbage, so we’ll want to clean that up too! We’ll also address adding Zurb Foundation 6 to our Phoenix application and create a cleaner design for our blogging platform!

Next Post In This Series

Writing A Blog Engine In Phoenix And Elixir: Part 10, Testing Channels

Check out my new book!

Hey everyone! If you liked what you read here and want to learn more with me, check out my new book on Elixir and Phoenix web development:

I’m really excited to finally be bringing this project to the world! It’s written in the same style as my other tutorials where we will be building the scaffold of a full project from start to finish, even covering some of the trickier topics like file uploads, Twitter/Google OAuth logins, and APIs!

--

--