Phoenix LiveView Tutorial: Bootstrap Your Messenger App

We're back with the first practical part of our Modern Talking with Elixir series, and this time we'll get the initial setup for our Phoenix LiveView-based Messenger app up and running.

We'll prepare our app for Phoenix LiveView and install all needed dependencies, design the app's Ecto schemas, related contexts, and database structure, to accommodate for the app's business logic.

As of November 2020, the latest Phoenix LiveView version is 0.14.8 - and the series has been updated to match it!

Table of contents

    To learn even more about how we'll build our Messenger app and get deeper into Phoenix LiveView, subscribe to our newsletter and await further episodes of the series!

    Initial steps: install Phoenix, create the project

    If these are your very first steps in Phoenix Framework, please install the framework's bootstrapping scripts - Phoenix's Hex documentation will be helpful for you.

    Needless to say, Elixir and Erlang need to be installed, as well as NodeJS - we recommend the asdf-vm extensible version manager as a tool that can manage all of them.

    Now create Phoenix's basic project structure using the installed script:

    mix phx.new curious_messenger
    cd curious_messenger

    You can then git init . if you'd like to track changes with Git, which is always highly recommended.

    Database configuration

    We won't stray from Phoenix's default setting and we're going to use PostgreSQL as our database for storing all of the app's data, including users, conversations, messages, and all metadata - have a look at PostgreSQL Wiki for guides on installing it.

    Phoenix creates a basic database configuration for all environments in config/dev.exs, config/test.exs and config/prod.exs. Database credentials shouldn't be shared in repositories - for production it would be a security concern, while for dev and test it's just annoying to your collaborators because everyone's got a slightly different setup. It is a good practice to keep separate dev.secret.exs and test.secret.exs files just as it's done by default with prod.secret.exs, and ignore them in source control; sample files can be provided for easier bootstrapping.

    So, let's create dev.secret.exs and test.secret.exs files in the same folder, with database config copied from the original ones - obviously, if you're using different usernames, or would like to change other options, just do so:

    # dev.secret.exs
    
    use Mix.Config
    
    # Configure your database
    config :curious_messenger, CuriousMessenger.Repo,
      username: "postgres",
      password: "postgres",
      database: "curious_messenger_dev",
      hostname: "localhost",
      show_sensitive_data_on_connection_error: true,
      pool_size: 10
    
    # test.secret.exs
    
    use Mix.Config
    
    # Configure your database
    config :curious_messenger, CuriousMessenger.Repo,
      username: "postgres",
      password: "postgres",
      database: "curious_messenger_test",
      hostname: "localhost",
      pool: Ecto.Adapters.SQL.Sandbox

    Put import_config "dev.secret.exs" and import_config "test.secret.exs" at the end of dev.exs and test.exs, respectively.

    Then, add /config/dev.secret.exs and /config/test.secret.exs to your .gitignore file. You can make copies of those files (with your credentials blanked out), intended to be tracked in source control, with .sample appended to their names if you'd like to keep things simple for those who clone your repository.

    This is a good moment to make an initial commit, by the way, if you'd like to.

    Now, let Ecto, which is Phoenix's default data access library, create the database in your local DB server for you:

    mix ecto.create

    Analyze requirements, define contexts and Ecto schemas

    Before we proceed to add LiveView to our project, let's design the data model driving the app's intended business logic.

    We want to store users communicating messages between them. Each message is part of a conversation, which is associated with two or more users, with a message always having a specified sender.

    As in most modern instant messaging apps, we want a "Message Seen" feature that tracks which conversation members have seen a message, in which every information about who's seen a message has a specific timestamp.

    We would also like to have a Slack-like emoji reaction system, in which any conversation member can react to a message with one or more defined emojis, all of which have a name and a Unicode representation.

    Phoenix Contexts

    Phoenix promotes the concept of contexts to organize your business logic code and encapsulate the data access layer. According to Phoenix docs: >The context is an Elixir module that serves as an API boundary for the given resource. A context often holds many related resources.

    The good thing about this approach is that we'll have our context modules talk to the Ecto schema modules, and Phoenix controllers will only talk to domain functions in the appropriate context modules, which will help us keep code clean and organized.

    Each context will hold one or more Ecto schemas serving as data mappers for our tables - based on our functional requirements, here's an outline of what structure we'll use:

    • An Auth context, containing the User schema. We'll keep this schema very basic for now, only containing the user's nickname, and augment it in later episodes when we get to integrate Pow for user authentication.

    • A Chat context, containing the following schemas:

      • Conversation, with a title, identifying a conversation.
      • ConversationMember, related to a conversation and a user, serving as a registration of a user within a conversation. For each conversation, one user can be its owner, who'll be able to e.g. close it.
      • Message, belonging to a conversation and a user who sent it, having a content.
      • SeenMessage, belonging to a user and a message, whose created_at timestamp denotes when the user first displayed the message.
      • Emoji, having a string key and a unicode representation, defining an emoji that can be used as a reaction.
      • MessageReaction, belonging to a message, user and emoji, with one user being able to react to a message with many emojis, but only once for each emoji.

    The Chat context could be split into smaller parts, but let's keep it simple for now and leave it as it is.

    Phoenix has a very handy phx.gen.context generator to automatically generate Ecto schemas, database migrations and CRUD functions for each schema, so we'll now use it to conveniently create those.

    mix phx.gen.context Auth User auth_users \
      nickname:string
    
    mix phx.gen.context Chat Conversation chat_conversations \
      title:string
    
    mix phx.gen.context Chat ConversationMember chat_conversation_members \
      conversation_id:references:chat_conversations \
      user_id:references:auth_users \
      owner:boolean
    
    mix phx.gen.context Chat Message chat_messages \
      conversation_id:references:chat_conversations \
      user_id:references:auth_users \
      content:text
    
    mix phx.gen.context Chat Emoji chat_emojis \
      key:string \
      unicode:string
    
    mix phx.gen.context Chat MessageReaction chat_message_reactions \
      message_id:references:chat_messages \
      user_id:references:auth_users \
      emoji_id:references:chat_emojis
    
    mix phx.gen.context Chat SeenMessage chat_seen_messages \
      user_id:references:auth_users \
      message_id:references:chat_messages
    

    This generates the CuriousMessenger.Auth and CuriousMessenger.Chat contexts with the Auth context, for instance, having list_auth_users, get_user!, create_user, update_user and delete_user functions.

    Respective Ecto schemas live in CuriousMessenger.Auth.User, CuriousMessenger.Chat.Conversation, etc., and have their fields automatically defined. Notice that we've prefixed all table names with the context name, e.g. auth_users for CuriousMessenger.Auth.User. We need some schema changes, though, as well as modifications to generated migrations.

    For the auth_users migration, we want a unique index on nicknames, as well as a non-null constraint, so look up the migration file with create_auth_users in name and make those changes:

    # Modify line in the "create table" block:
    add :nickname, :string, null: false
    
    # Append at the end of `change` function:
    create unique_index(:auth_users, [:nickname])

    And modify the User schema accordingly in user.ex:

    # Validate nickname presence and uniqueness:
    def changeset(user, attrs) do
      user
      |> cast(attrs, [:nickname])
      |> validate_required([:nickname])
      |> unique_constraint(:nickname)
    end

    In the chat_conversations migration, let's ensure that its title is present:

    add :title, :string, null: false

    Let's reflect this in the Chat.Conversation schema, and define the relationship between Conversation and ConversationMember and Message:

    alias CuriousMessenger.Chat.{ConversationMember, Message}
    
    schema "chat_conversations" do
      field :title, :string
    
      has_many :conversation_members, ConversationMember
      has_many :messages, Message
    
      timestamps()
    end
    
    @doc false
    def changeset(conversation, attrs) do
      conversation
      |> cast(attrs, [:title])
      |> validate_required([:title])
    end

    Let's now link Conversation to User using ConversationMember. Open up the migration - here's how it should look like. Notice that we've added not-null constraints to conversation_id and user_id, and we've created two interesting unique indexes.

    def change do
      create table(:chat_conversation_members) do
        add :owner, :boolean, default: false, null: false
        add :conversation_id, references(:chat_conversations, on_delete: :nothing), null: false
        add :user_id, references(:auth_users, on_delete: :nothing), null: false
    
        timestamps()
      end
    
      create index(:chat_conversation_members, [:conversation_id])
      create index(:chat_conversation_members, [:user_id])
      create unique_index(:chat_conversation_members, [:conversation_id, :user_id])
    
      create unique_index(:chat_conversation_members, [:conversation_id],
             where: "owner = TRUE",
             name: "chat_conversation_members_owner"
           )
    end

    The first unique index ensures that one user can be associated with each conversation only once, which is logical. The second one is a PostgreSQL partial index only created on the table's records with owner set to true, which means that only one conversation member record with a given conversation_id will ever be the conversation's owner.

    Now we need to reflect the not-null constraints in the schema, as well as using the unique constraints.

    alias CuriousMessenger.Auth.User
    alias CuriousMessenger.Chat.Conversation
    
    schema "chat_conversation_members" do
      field :owner, :boolean, default: false
    
      belongs_to :user, User
      belongs_to :conversation, Conversation
    
      timestamps()
    end
    
    @doc false
    def changeset(conversation_member, attrs) do
      conversation_member
      |> cast(attrs, [:owner, :conversation_id, :user_id])
      |> validate_required([:owner, :conversation_id, :user_id])
      |> unique_constraint(:user, name: :chat_conversation_members_conversation_id_user_id_index)
      |> unique_constraint(:conversation_id,
        name: :chat_conversation_members_owner
      )
    end

    Note that we specified the names of unique constraints, beacuse these indexes are on multiple columns - the first one was automatically generated by Ecto, the second one was a name of our choice, describing the purpose of that index - related to the conversation owner.

    For Message, let's change the migration to define not-null constraints:

    add :conversation_id, references(:chat_conversations, on_delete: :nothing), null: false
    add :user_id, references(:auth_users, on_delete: :nothing), null: false

    And have the schema define relationship definitions - a message belongs to a conversation and a user, and has many seen message records and emoji reactions:

    alias CuriousMessenger.Auth.User
    alias CuriousMessenger.Chat.{Conversation, SeenMessage, MessageReaction}
    
    schema "chat_messages" do
      field :content, :string
    
      belongs_to :conversation, Conversation
      belongs_to :user, User
    
      has_many :seen_messages, SeenMessage
      has_many :message_reactions, MessageReaction
    
      timestamps()
    end
    
    @doc false
    def changeset(message, attrs) do
      message
      |> cast(attrs, [:content, :conversation_id, :user_id])
      |> validate_required([:content, :conversation_id, :user_id])
    end

    For SeenMessage, let's add not-null constraints and an unique index:

    add :user_id, references(:auth_users, on_delete: :nothing), null: false
    add :message_id, references(:chat_messages, on_delete: :nothing), null: false
    
    # ...
    
    create unique_index(:chat_seen_messages, [:user_id, :message_id])

    Let's also update the schema:

    alias CuriousMessenger.Auth.User
    alias CuriousMessenger.Chat.Message
    
    schema "chat_seen_messages" do
      belongs_to :user, User
      belongs_to :message, Message
    
      timestamps()
    end
    
    @doc false
    def changeset(seen_message, attrs) do
      seen_message
      |> cast(attrs, [:user_id, :message_id])
      |> validate_required([:user_id, :message_id])
    end

    Emoji needs to have not-null constraints in the migration:

    add :key, :string, null: false
    add :unicode, :string, null: false

    As well as a requirement validation for those in the schema in emoji.ex:

    def changeset(emoji, attrs) do
      emoji
      |> cast(attrs, [:key, :unicode])
      |> validate_required([:key, :unicode])
    end

    Finally, going to MessageReaction, let's add not-null and uniqueness constraints in the migration:

    def change do
      create table(:chat_message_reactions) do
        add :message_id, references(:chat_messages, on_delete: :nothing), null: false
        add :user_id, references(:auth_users, on_delete: :nothing), null: false
        add :emoji_id, references(:chat_emojis, on_delete: :nothing), null: false
    
        timestamps()
      end
    
      create index(:chat_message_reactions, [:message_id])
      create index(:chat_message_reactions, [:user_id])
      create index(:chat_message_reactions, [:emoji_id])
    
      create unique_index(:chat_message_reactions, [:user_id, :message_id, :emoji_id])
    end

    Let's also modify the schema:

    alias CuriousMessenger.Auth.User
    alias CuriousMessenger.Chat.Emoji
    alias CuriousMessenger.Chat.Message
    
    schema "chat_message_reactions" do
      belongs_to :user, User
      belongs_to :emoji, Emoji
      belongs_to :message, Message
    
      timestamps()
    end
    
    @doc false
    def changeset(message_reaction, attrs) do
      message_reaction
      |> cast(attrs, [:user_id, :emoji_id, :message_id])
      |> validate_required([:user_id, :emoji_id, :message_id])
      |> unique_constraint(:emoji_id,
        name: :chat_message_reactions_user_id_message_id_emoji_id_index
      )
    end

    OK, this was understandably boring - but let's now do mix ecto.migrate and enjoy while Ecto creates your tables!

    We'll come back to playing around with our contexts later. Now, if you're using Git, it's a good time to commit your changes - and then let's move on to initial steps in LiveView installation in your project.

    Installing LiveView

    Phoenix LiveView is not a default dependency in Phoenix, so we need to add it to your project's mix.exs file, after which mix deps.get needs to be executed.

    defp deps do
    [
      # ...,
      {:phoenix_live_view, "~> 0.14.8"}
    ]
    end

    A few configuration steps need to be taken now. We need to configure a signing salt, which is a mechanism that prevents man-in-the-middle attacks.

    A secret value can be securely generated using:

    mix phx.gen.secret 32

    Then, paste it into config/config.exs:

    config :curious_messenger, CuriousMessengerWeb.Endpoint,
      #...,
      live_view: [
        signing_salt: "pasted_salt"
      ]

    We need to ensure that LiveView can fetch flash messages - which is a mechanism typically used to present messages after HTTP redirects. For this to work, let's add to router.ex around the current flash plug declaration. Let's also add a root layout declaration for the app, which I'll explain in a moment.

    defmodule CuriousMessengerWeb.Router do
      pipeline :browser do
        # ...
    
        plug :fetch_flash
        plug :fetch_live_flash # add this between "non-live" flash and forgery protection
        plug :protect_from_forgery
    
        # ...
    
        # add this add the end of the :browser pipeline
        plug :put_root_layout, {CuriousMessengerWeb.LayoutView, :root}
      end
    end

    The purpose of using the :put_root_layout plug is to ensure that LiveView layouts and plain Phoenix layouts use a common template as their basis.

    Rename lib/curious_messenger_web/templates/layout/app.html.eex to root.html.eex and remove the two lines in the <main> tag:

    <p class="alert alert-info" role="alert"><%= get_flash(@conn, :info) %></p>
    <p class="alert alert-danger" role="alert"><%= get_flash(@conn, :error) %></p>

    so that the <main> tag looks like this:

    <main role="main" class="container">
      <%= @inner_content %>
    </main>

    Let's now create a new lib/curious_messenger_web/templates/layout/app.html.eex file just as the following - omitting all other lines:

    <p class="alert alert-info" role="alert"><%= get_flash(@conn, :info) %></p>
    <p class="alert alert-danger" role="alert"><%= get_flash(@conn, :error) %></p>
    <%= @inner_content %>

    Then, create a live.html.leex template in the same folder (note the .leex extension that ensures LiveView rendering is used) with the following content. Notice that we're using live_flash helper instead of get_flash so in this case flash is retrieved from LiveView state and not the standard HTTP session.

    <p class="alert alert-info" role="alert"><%= live_flash(@flash, :notice) %></p>
    <p class="alert alert-danger" role="alert"><%= live_flash(@flash, :error) %></p>
    <%= @inner_content %>

    Our common codebase for controllers, views and the router - living in lib/curious_messenger_web.ex - needs to include LiveView-related functions from Phoenix.LiveView.Controller, Phoenix.LiveView and Phoenix.LiveView.Router.

    def controller do
      quote do
        # ...
        import Phoenix.LiveView.Controller
      end
    end
    
    def view do
      quote do
        # ...
        import Phoenix.LiveView, only: [live_render: 2, live_render: 3, live_link: 1, live_link: 2]
      end
    end
    
    def router do
      quote do
        # ...
        import Phoenix.LiveView.Router
      end
    end

    In case you're wondering what the quote blocks mean - do read our earlier Elixir Trickery: Using Macros & Metaprogramming Without Superpowers article to find out!

    Since Phoenix LiveView is based on WebSockets, a bidirectional protocol for full-duplex communication, which is very different from HTTP, in endpoint.ex you need to add the following to declare that /live is the path used for establishing WebSocket connection between Phoenix server and the browser.

    defmodule CuriousMessengerWeb.Endpoint do
      use Phoenix.Endpoint, otp_app: :curious_messenger
    
      socket "/live", Phoenix.LiveView.Socket
      # ...

    LiveView will let us code reactive UIs with virtually no JavaScript code. This, however, comes at a price of having to include a JS bundle that will take care of handling the WebSocket connection, sending and receiving messages, and updating the HTML DOM.

    Thankfully, our experience tells us that it's not detrimental to page performance, so you shouldn't probably worry about it too much in the context of SEO and performance audits.

    Add the following to assets/package.json:

    "dependencies": {
      "phoenix_live_view": "file:../deps/phoenix_live_view"
    }

    Run npm install in the assets folder, and then append this to app.js to initialize LiveView's WebSocket connection:

    import { Socket } from "phoenix"
    import LiveSocket from "phoenix_live_view"
    
    let liveSocket = new LiveSocket("/live", Socket)
    liveSocket.connect()

    It's a good moment to commit the changes in your Git repository if you maintain one.

    Your first LiveView

    Since Phoenix LiveView is the main concern of this tutorial series, let's try creating our first LiveView-based page.

    We'll let you get into a conversation between two users, and do a simple message exchange between them.

    For now, we'll just pre-populate user, conversation and conversation membership records, since we don't have a better way to create those yet - we'll surely make it up in next episodes!

    Add the following to priv/repo/seeds.exs:

    alias CuriousMessenger.Auth.User
    alias CuriousMessenger.Chat.{Conversation, ConversationMember}
    
    alias CuriousMessenger.{Auth, Chat}
    
    {:ok, %User{id: u1_id}} = Auth.create_user(%{nickname: "User One"})
    {:ok, %User{id: u2_id}} = Auth.create_user(%{nickname: "User Two"})
    
    {:ok, %Conversation{id: conv_id}} = Chat.create_conversation(%{title: "Modern Talking"})
    
    {:ok, %ConversationMember{}} =
      Chat.create_conversation_member(%{conversation_id: conv_id, user_id: u1_id, owner: true})
    
    {:ok, %ConversationMember{}} =
      Chat.create_conversation_member(%{conversation_id: conv_id, user_id: u2_id, owner: false})

    Then, run it with:

    mix run priv/repo/seeds.exs

    The database now contains two pre-populated user records and a conversation between them.

    Now add to the scope "/", CuriousMessengerWeb block in router.ex:

    live "/conversations/:conversation_id/users/:user_id", ConversationLive

    This will point this route to the CuriousMessengerWeb.ConversationLive LiveView module that we'll soon create, which will render a live view of a conversation with conversation_id id in the context of the user identified by user_id.

    Now we'll create the lib/curious_messenger_web/live/conversation_live.ex file. Let the initial version contain the skeleton for a couple of Phoenix.LiveView behaviour's callbacks that we'll need to implement.

    defmodule CuriousMessengerWeb.ConversationLive do
      use Phoenix.LiveView
      use Phoenix.HTML
    
      alias CuriousMessenger.{Auth, Chat, Repo}
    
      def render(assigns) do
        ...
      end
    
      def mount(assigns, socket) do
        ...
      end
    
      def handle_event(event, payload, socket) do
        ...
      end
    
      def handle_params(params, uri, socket) do
        ...
      end
    end

    The roles of these callbacks are:

    • mount/2 is the callback that runs right at the beginning of LiveView's lifecycle, wiring up socket assigns necessary for rendering the view. Since we're running a page which needs to load records based on URI params, and mount/2 has no access to those, we'll just keep it trivial:
      def mount(_assigns, socket) do
        {:ok, socket}
      end
    • handle_params/3 runs after mount and this is the stage at which we can read the query params supplied. We won't always do this, because we'll often render a LiveView as part of a larger template, not directly via a defined route; but in this case, we need to use it. Later, it can also intercept parameter changes during your stay on the page, so that it won't have to always instantiate a new LiveView process.
      def handle_params(%{"conversation_id" => conversation_id, "user_id" => user_id}, _uri, socket) do
        {:noreply,
          socket
          |> assign(:user_id, user_id)
          |> assign(:conversation_id, conversation_id)
          |> assign_records()}
      end
    
      # A private helper function to retrieve needed records from the DB
      defp assign_records(%{assigns: %{user_id: user_id, conversation_id: conversation_id}} = socket) do
        user = Auth.get_user!(user_id)
    
        conversation =
          Chat.get_conversation!(conversation_id)
          |> Repo.preload(messages: [:user], conversation_members: [:user])
    
        socket
        |> assign(:user, user)
        |> assign(:conversation, conversation)
        |> assign(:messages, conversation.messages)
      end

    The handle_params/3 function signature has pattern matching on the parameters, ignores the URI and assigns state to the socket behind the LiveView, similarly to how one would do this with a Plug.Conn.

    • render/1 defines the rendered template and uses the socket's assigns to read the current LiveView's state. It uses the ~L sigil to compile a template with assigns, and the template will be re-rendered every time a dynamic portion of it changes (see documentation for Phoenix.LiveView.Engine for more details, because it's a really interesting process).
      We'll assume that, on every render, the page will contain assigns for user, denoting current user, conversation, including data about current conversation, and messages, containing all of the conversation's messages.
      def render(assigns) do
        ~L"""
        <div>
          <b>User name:</b> <%= @user.nickname %>
        </div>
        <div>
          <b>Conversation title:</b> <%= @conversation.title %>
        </div>
        <div>
          <%= f = form_for :message, "#", [phx_submit: "send_message"] %>
            <%= label f, :content %>
            <%= text_input f, :content %>
            <%= submit "Send" %>
          </form>
        </div>
        <div>
          <b>Messages:</b>
          <%= for message <- @messages do %>
            <div>
              <b><%= message.user.nickname %></b>: <%= message.content %>
            </div>
          <% end %>
        </div>
        """
      end
    • handle_event/3, whose role is to process events triggered by code running in the browser. Noticed the phx_submit attribute we applied to the form in render/1? This indicates that, over the WebSocket connection, an event named send_message will be sent. The second argument of handle_event/3 will receive all of the event's metadata, which will allow us to read the value from our form. Then, it'll create a new message record in the database and append it to the socket's assigns, which will cause the LiveView to re-render.
      def handle_event(
            "send_message",
            %{"message" => %{"content" => content}},
            %{assigns: %{conversation_id: conversation_id, user_id: user_id, user: user}} = socket
          ) do
        case Chat.create_message(%{
              conversation_id: conversation_id,
              user_id: user_id,
              content: content
            }) do
          {:ok, new_message} ->
            new_message = %{new_message | user: user}
            updated_messages = socket.assigns[:messages] ++ [new_message]
    
            {:noreply, socket |> assign(:messages, updated_messages)}
    
          {:error, _} ->
            {:noreply, socket}
        end
      end

    Testing it out, and what to do next

    We've got the current state of our repository tagged in GitHub, so check it out if you'd just like to test it out.

    There you go! Run mix phx.server and navigate to localhost:4000/conversations/1/users/1 in your browser (assuming you've run your seeds.exs file).

    Initial version of Phoenix LiveView-based Messenger app.

    You can send messages to the app, they'll appear on the page, and they'll still be there after you refresh it or restart the server.

    Try opening a separate window and navigating to localhost:4000/conversations/1/users/2. You'll notice that it works too and you can see the messages already sent from the other window.

    What's crucially missing, though, is the ability for one LiveView to instantly update the other one with new messages. You'll notice that sending a message in one window doesn't update the other one unless you refresh it. This means it's not very useful at this stage - we'll do much better than this, though, and this will be covered in the next episode of our Modern Talking with Elixir series - don't forget to subscribe if you'd like to learn more!

    Michał Buszkiewicz, Elixir Developer
    Michał Buszkiewicz Curiosum Founder & CTO

    Read more
    on #curiosum blog

    Phoenix LiveView Tutorial: Adding Phoenix PubSub and Pow Authentication to Messenger

    We've already bootstrapped our Phoenix LiveView-based Messenger app's database structure and a first LiveView page.

    This time, we're going to improve real-time communication between the app's users using Phoenix PubSub, and use the Pow library to add secure user authentication.

    As of November 2020, the latest Phoenix LiveView version is 0.14.8 - and the series has been updated to match it!

    5 top-tier companies that use Elixir

    Elixir is a pretty capable language - and it consistently ranks near the top of most loved and wanted languages rankings. It has a large following and some very persuasive preachers as well. But that would not be enough to make me like it – what I need as real proof of its strengths is real businesses that strive with Elixir.

    That’s what this list is all about – a bunch of stories from top companies that chose Elixir and never looked back. Let us show you how its power and versatility shows in practice.