Building Real-Time Applications: LiveView + Livewire Implementation Guide

Real-time applications are the backbone of modern web experiences. Whether it’s chat systems, live notifications, or collaborative editing, Phoenix LiveView and Laravel Livewire make building these features surprisingly straightforward.

Let’s build comprehensive real-time applications with both frameworks.

Project Overview: Real-Time Collaboration Suite

We’ll build a collaboration platform with:

  • Live chat rooms
  • Real-time notifications
  • Collaborative document editing
  • User presence tracking
  • Live activity feeds

Phoenix LiveView Implementation

1. Project Setup

# Create new Phoenix project
mix phx.new realtime_app --live

# Add dependencies
# mix.exs
defp deps do
  [
    {:phoenix, "~> 1.7"},
    {:phoenix_live_view, "~> 0.20"},
    {:phoenix_pubsub, "~> 2.1"},
    {:presence, "~> 1.1"},
    {:jason, "~> 1.0"}
  ]
end

2. Real-Time Chat System

lib/realtime_app_web/live/chat_live.ex

defmodule RealtimeAppWeb.ChatLive do
  use RealtimeAppWeb, :live_view
  alias RealtimeApp.Chat
  alias RealtimeApp.Presence
  
  def mount(%{"room_id" => room_id}, _session, socket) do
    room = Chat.get_room!(room_id)
    user = socket.assigns.current_user
    
    socket = 
      socket
      |> assign(:room, room)
      |> assign(:message, "")
      |> assign(:messages, Chat.list_messages(room_id))
      |> assign(:online_users, %{})
    
    if connected?(socket) do
      # Subscribe to chat updates
      Phoenix.PubSub.subscribe(RealtimeApp.PubSub, "room:#{room_id}")
      
      # Track user presence
      {:ok, _} = Presence.track(self(), "room:#{room_id}", user.id, %{
        username: user.username,
        joined_at: System.system_time(:second)
      })
      
      # Subscribe to presence updates  
      Phoenix.PubSub.subscribe(RealtimeApp.PubSub, "room:#{room_id}:presence")
    end
    
    {:ok, socket}
  end
  
  def handle_event("send_message", %{"message" => content}, socket) do
    %{room: room, current_user: user} = socket.assigns
    
    case Chat.create_message(%{
      content: content,
      room_id: room.id,
      user_id: user.id
    }) do
      {:ok, message} ->
        # Broadcast to all room members
        Phoenix.PubSub.broadcast(
          RealtimeApp.PubSub,
          "room:#{room.id}",
          {:new_message, message}
        )
        
        {:noreply, assign(socket, :message, "")}
        
      {:error, _} ->
        {:noreply, put_flash(socket, :error, "Failed to send message")}
    end
  end
  
  def handle_event("typing", %{"typing" => typing}, socket) do
    %{room: room, current_user: user} = socket.assigns
    
    Phoenix.PubSub.broadcast(
      RealtimeApp.PubSub,
      "room:#{room.id}",
      {:user_typing, user.id, typing}
    )
    
    {:noreply, socket}
  end
  
  # Handle real-time message broadcasts
  def handle_info({:new_message, message}, socket) do
    message = RealtimeApp.Repo.preload(message, :user)
    
    updated_messages = socket.assigns.messages ++ [message]
    {:noreply, assign(socket, :messages, updated_messages)}
  end
  
  # Handle typing indicators
  def handle_info({:user_typing, user_id, typing}, socket) do
    # Update typing indicator state
    typing_users = 
      if typing do
        MapSet.put(socket.assigns.typing_users || MapSet.new(), user_id)
      else
        MapSet.delete(socket.assigns.typing_users || MapSet.new(), user_id)
      end
    
    {:noreply, assign(socket, :typing_users, typing_users)}
  end
  
  # Handle presence updates
  def handle_info({:presence_diff, diff}, socket) do
    online_users = 
      socket.assigns.online_users
      |> Presence.sync_diff(diff)
    
    {:noreply, assign(socket, :online_users, online_users)}
  end
  
  def render(assigns) do
    ~H"""
    <div class="flex h-screen bg-gray-100">
      <!-- Sidebar: Online Users -->
      <div class="w-1/4 bg-white border-r">
        <div class="p-4 border-b">
          <h2 class="text-lg font-semibold">Online Users</h2>
        </div>
        <div class="p-4">
          <%= for {user_id, %{metas: metas}} <- @online_users do %>
            <% [meta | _] = metas %>
            <div class="flex items-center gap-2 mb-2">
              <div class="w-2 h-2 bg-green-500 rounded-full"></div>
              <span class="text-sm"><%= meta.username %></span>
            </div>
          <% end %>
        </div>
      </div>
      
      <!-- Main Chat -->
      <div class="flex-1 flex flex-col">
        <!-- Messages -->
        <div class="flex-1 overflow-y-auto p-4" phx-hook="ScrollToBottom" id="messages">
          <%= for message <- @messages do %>
            <div class="mb-4">
              <div class="flex items-baseline gap-2">
                <span class="font-semibold text-sm"><%= message.user.username %></span>
                <span class="text-xs text-gray-500">
                  <%= Calendar.strftime(message.inserted_at, "%H:%M") %>
                </span>
              </div>
              <p class="text-gray-800 mt-1"><%= message.content %></p>
            </div>
          <% end %>
          
          <!-- Typing Indicators -->
          <%= if MapSet.size(@typing_users || MapSet.new()) > 0 do %>
            <div class="text-sm text-gray-500 italic">
              Someone is typing...
            </div>
          <% end %>
        </div>
        
        <!-- Message Input -->
        <div class="border-t p-4">
          <form phx-submit="send_message" class="flex gap-2">
            <input
              type="text"
              name="message"
              value={@message}
              placeholder="Type a message..."
              class="flex-1 px-3 py-2 border rounded-lg"
              phx-keyup="typing"
              phx-debounce="300"
            />
            <button 
              type="submit"
              class="px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600"
            >
              Send
            </button>
          </form>
        </div>
      </div>
    </div>
    """
  end
end

3. Collaborative Document Editing

lib/realtime_app_web/live/document_live.ex

defmodule RealtimeAppWeb.DocumentLive do
  use RealtimeAppWeb, :live_view
  alias RealtimeApp.Documents
  alias RealtimeApp.OperationalTransform
  
  def mount(%{"id" => doc_id}, _session, socket) do
    document = Documents.get_document!(doc_id)
    
    socket = 
      socket
      |> assign(:document, document)
      |> assign(:content, document.content)
      |> assign(:cursors, %{})
      |> assign(:version, document.version)
    
    if connected?(socket) do
      Phoenix.PubSub.subscribe(RealtimeApp.PubSub, "document:#{doc_id}")
    end
    
    {:ok, socket}
  end
  
  def handle_event("text_change", params, socket) do
    %{"content" => new_content, "selection" => selection} = params
    %{document: document, current_user: user, version: current_version} = socket.assigns
    
    # Create operation for Operational Transform
    operation = OperationalTransform.create_operation(
      socket.assigns.content,
      new_content,
      current_version
    )
    
    # Apply operation and update document
    case Documents.apply_operation(document, operation, user) do
      {:ok, updated_document} ->
        # Broadcast operation to all collaborators
        Phoenix.PubSub.broadcast(
          RealtimeApp.PubSub,
          "document:#{document.id}",
          {:operation, operation, user.id}
        )
        
        # Broadcast cursor position
        Phoenix.PubSub.broadcast(
          RealtimeApp.PubSub,
          "document:#{document.id}",
          {:cursor_move, user.id, selection}
        )
        
        {:noreply, assign(socket, 
          content: updated_document.content,
          version: updated_document.version
        )}
        
      {:error, :conflict} ->
        # Handle conflict with OT resolution
        {:noreply, put_flash(socket, :error, "Document conflict, refreshing...")}
    end
  end
  
  def handle_info({:operation, operation, user_id}, socket) do
    # Don't apply our own operations
    if user_id != socket.assigns.current_user.id do
      new_content = OperationalTransform.apply_operation(
        socket.assigns.content, 
        operation
      )
      
      {:noreply, assign(socket, 
        content: new_content,
        version: socket.assigns.version + 1
      )}
    else
      {:noreply, socket}
    end
  end
  
  def handle_info({:cursor_move, user_id, selection}, socket) do
    updated_cursors = Map.put(socket.assigns.cursors, user_id, selection)
    {:noreply, assign(socket, :cursors, updated_cursors)}
  end
  
  def render(assigns) do
    ~H"""
    <div class="h-screen flex flex-col">
      <div class="bg-white border-b p-4">
        <h1 class="text-xl font-semibold"><%= @document.title %></h1>
      </div>
      
      <div class="flex-1 p-4">
        <div class="relative">
          <textarea
            phx-keyup="text_change"
            phx-debounce="100"
            class="w-full h-96 p-4 border rounded-lg resize-none"
            phx-hook="DocumentEditor"
          ><%= @content %></textarea>
          
          <!-- Cursor indicators for other users -->
          <%= for {user_id, cursor} <- @cursors do %>
            <div 
              class="absolute w-0.5 h-6 bg-blue-500"
              style={"top: #{cursor.top}px; left: #{cursor.left}px;"}
            >
              <div class="absolute -top-6 left-0 bg-blue-500 text-white text-xs px-1 rounded">
                User <%= user_id %>
              </div>
            </div>
          <% end %>
        </div>
      </div>
    </div>
    """
  end
end

Laravel Livewire Implementation

1. Project Setup

# Create Laravel project
composer create-project laravel/laravel realtime-collab
cd realtime-collab

# Install Livewire
composer require livewire/livewire

# Install broadcasting dependencies
composer require pusher/pusher-php-server
npm install --save-dev laravel-echo pusher-js

2. Real-Time Chat with Broadcasting

app/Http/Livewire/ChatRoom.php

<?php

namespace App\Http\Livewire;

use App\Events\MessageSent;
use App\Events\UserTyping;
use App\Models\ChatRoom as Room;
use App\Models\Message;
use Illuminate\Support\Facades\Auth;
use Livewire\Component;

class ChatRoom extends Component
{
    public $roomId;
    public $room;
    public $messages;
    public $newMessage = '';
    public $onlineUsers = [];
    public $typingUsers = [];
    
    protected $listeners = [
        'echo:chat.{roomId},MessageSent' => 'messageReceived',
        'echo:chat.{roomId},UserTyping' => 'userTyping',
        'echo-presence:chat.{roomId},here' => 'usersHere',
        'echo-presence:chat.{roomId},joining' => 'userJoining',
        'echo-presence:chat.{roomId},leaving' => 'userLeaving',
    ];
    
    public function mount($roomId)
    {
        $this->roomId = $roomId;
        $this->room = Room::findOrFail($roomId);
        $this->loadMessages();
    }
    
    public function loadMessages()
    {
        $this->messages = Message::where('room_id', $this->roomId)
            ->with('user')
            ->orderBy('created_at')
            ->get();
    }
    
    public function sendMessage()
    {
        $this->validate([
            'newMessage' => 'required|max:500'
        ]);
        
        $message = Message::create([
            'room_id' => $this->roomId,
            'user_id' => Auth::id(),
            'content' => $this->newMessage
        ]);
        
        $message->load('user');
        
        // Broadcast to all room members
        broadcast(new MessageSent($message, $this->roomId));
        
        $this->newMessage = '';
        $this->emit('messageAdded');
    }
    
    public function updatedNewMessage()
    {
        // Broadcast typing indicator
        broadcast(new UserTyping(Auth::user(), $this->roomId, !empty($this->newMessage)));
    }
    
    // Event listeners
    public function messageReceived($event)
    {
        $this->messages->push((object) $event['message']);
        $this->dispatchBrowserEvent('scrollToBottom');
    }
    
    public function userTyping($event)
    {
        $userId = $event['user']['id'];
        $isTyping = $event['typing'];
        
        if ($userId !== Auth::id()) {
            if ($isTyping) {
                $this->typingUsers[$userId] = $event['user'];
            } else {
                unset($this->typingUsers[$userId]);
            }
        }
    }
    
    public function usersHere($users)
    {
        $this->onlineUsers = collect($users)->keyBy('id')->toArray();
    }
    
    public function userJoining($user)
    {
        $this->onlineUsers[$user['id']] = $user;
    }
    
    public function userLeaving($user)
    {
        unset($this->onlineUsers[$user['id']]);
    }
    
    public function render()
    {
        return view('livewire.chat-room');
    }
}

resources/views/livewire/chat-room.blade.php

<div class="flex h-screen bg-gray-100">
    <!-- Sidebar: Online Users -->
    <div class="w-1/4 bg-white border-r">
        <div class="p-4 border-b">
            <h2 class="text-lg font-semibold">Online Users ()</h2>
        </div>
        <div class="p-4">
            @foreach($onlineUsers as $user)
                <div class="flex items-center gap-2 mb-2">
                    <div class="w-2 h-2 bg-green-500 rounded-full"></div>
                    <span class="text-sm"></span>
                </div>
            @endforeach
        </div>
    </div>
    
    <!-- Main Chat -->
    <div class="flex-1 flex flex-col">
        <!-- Messages -->
        <div class="flex-1 overflow-y-auto p-4" id="messages-container">
            @foreach($messages as $message)
                <div class="mb-4">
                    <div class="flex items-baseline gap-2">
                        <span class="font-semibold text-sm"></span>
                        <span class="text-xs text-gray-500">
                            
                        </span>
                    </div>
                    <p class="text-gray-800 mt-1"></p>
                </div>
            @endforeach
            
            <!-- Typing Indicators -->
            @if(count($typingUsers) > 0)
                <div class="text-sm text-gray-500 italic">
                     
                     typing...
                </div>
            @endif
        </div>
        
        <!-- Message Input -->
        <div class="border-t p-4">
            <form wire:submit.prevent="sendMessage" class="flex gap-2">
                <input
                    type="text"
                    wire:model.debounce.300ms="newMessage"
                    placeholder="Type a message..."
                    class="flex-1 px-3 py-2 border rounded-lg"
                    maxlength="500"
                />
                <button 
                    type="submit"
                    class="px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600"
                    wire:loading.attr="disabled"
                >
                    <span wire:loading.remove>Send</span>
                    <span wire:loading>Sending...</span>
                </button>
            </form>
        </div>
    </div>
    
    <script>
        // Auto-scroll to bottom
        window.addEventListener('messageAdded', () => {
            const container = document.getElementById('messages-container');
            container.scrollTop = container.scrollHeight;
        });
        
        window.addEventListener('scrollToBottom', () => {
            const container = document.getElementById('messages-container');
            container.scrollTop = container.scrollHeight;
        });
    </script>
</div>

3. Collaborative Document Editing

app/Http/Livewire/DocumentEditor.php

<?php

namespace App\Http\Livewire;

use App\Events\DocumentUpdated;
use App\Events\CursorMoved;
use App\Models\Document;
use App\Services\OperationalTransform;
use Illuminate\Support\Facades\Auth;
use Livewire\Component;

class DocumentEditor extends Component
{
    public $documentId;
    public $document;
    public $content;
    public $version;
    public $cursors = [];
    public $lastOperation = null;
    
    protected $listeners = [
        'echo:document.{documentId},DocumentUpdated' => 'documentUpdated',
        'echo:document.{documentId},CursorMoved' => 'cursorMoved',
    ];
    
    public function mount($documentId)
    {
        $this->documentId = $documentId;
        $this->document = Document::findOrFail($documentId);
        $this->content = $this->document->content;
        $this->version = $this->document->version;
    }
    
    public function updatedContent($value)
    {
        // Prevent infinite loops from remote updates
        if ($this->lastOperation && $this->lastOperation['from_remote']) {
            $this->lastOperation = null;
            return;
        }
        
        // Create operational transform operation
        $operation = OperationalTransform::createOperation(
            $this->document->content,
            $value,
            $this->version
        );
        
        try {
            // Apply operation with conflict resolution
            $updatedDocument = OperationalTransform::applyOperation(
                $this->document,
                $operation,
                Auth::user()
            );
            
            // Broadcast to collaborators
            broadcast(new DocumentUpdated(
                $this->documentId,
                $operation,
                Auth::id(),
                $updatedDocument->version
            ));
            
            // Update local state
            $this->document = $updatedDocument;
            $this->version = $updatedDocument->version;
            
        } catch (ConflictException $e) {
            // Handle conflicts by reloading
            $this->mount($this->documentId);
            $this->dispatchBrowserEvent('documentConflict', [
                'message' => 'Document was updated by another user. Refreshing...'
            ]);
        }
    }
    
    public function moveCursor($selection)
    {
        broadcast(new CursorMoved(
            $this->documentId,
            Auth::id(),
            Auth::user()->name,
            $selection
        ));
    }
    
    // Event listeners
    public function documentUpdated($event)
    {
        if ($event['userId'] !== Auth::id()) {
            $newContent = OperationalTransform::applyRemoteOperation(
                $this->content,
                $event['operation']
            );
            
            $this->lastOperation = ['from_remote' => true];
            $this->content = $newContent;
            $this->version = $event['version'];
        }
    }
    
    public function cursorMoved($event)
    {
        if ($event['userId'] !== Auth::id()) {
            $this->cursors[$event['userId']] = [
                'name' => $event['userName'],
                'selection' => $event['selection']
            ];
        }
    }
    
    public function render()
    {
        return view('livewire.document-editor');
    }
}

Broadcasting Configuration

Phoenix PubSub Setup

config/config.exs

config :realtime_app, RealtimeApp.PubSub,
  adapter: Phoenix.PubSub.PG2

# For clustering
config :realtime_app, RealtimeAppWeb.Endpoint,
  pubsub_server: RealtimeApp.PubSub

Laravel Broadcasting Setup

config/broadcasting.php

'connections' => [
    'pusher' => [
        'driver' => 'pusher',
        'key' => env('PUSHER_APP_KEY'),
        'secret' => env('PUSHER_APP_SECRET'),
        'app_id' => env('PUSHER_APP_ID'),
        'options' => [
            'cluster' => env('PUSHER_APP_CLUSTER'),
            'useTLS' => true,
        ],
    ],
],

resources/js/bootstrap.js

import Echo from 'laravel-echo';
import Pusher from 'pusher-js';

window.Pusher = Pusher;

window.Echo = new Echo({
    broadcaster: 'pusher',
    key: process.env.MIX_PUSHER_APP_KEY,
    cluster: process.env.MIX_PUSHER_APP_CLUSTER,
    forceTLS: true,
    authorizer: (channel, options) => {
        return {
            authorize: (socketId, callback) => {
                axios.post('/broadcasting/auth', {
                    socket_id: socketId,
                    channel_name: channel.name
                })
                .then(response => {
                    callback(false, response.data);
                })
                .catch(error => {
                    callback(true, error);
                });
            }
        };
    },
});

Performance Optimizations

Phoenix LiveView Optimizations

defmodule OptimizedLive do
  use Phoenix.LiveView
  
  # Reduce update frequency
  @impl true
  def handle_info(:update, socket) do
    if socket.assigns.last_update + 100 < System.system_time(:millisecond) do
      {:noreply, assign(socket, last_update: System.system_time(:millisecond))}
    else
      {:noreply, socket}
    end
  end
  
  # Optimize rendering with temporary assigns
  def render(assigns) do
    ~H"""
    <div phx-update="stream" id="messages">
      <%= for {id, message} <- @streams.messages do %>
        <div id={id}><%= message.content %></div>
      <% end %>
    </div>
    """
  end
end

Laravel Livewire Optimizations

class OptimizedComponent extends Component
{
    // Lazy load heavy data
    public function getExpensiveDataProperty()
    {
        return cache()->remember('expensive-data', 300, function () {
            return $this->calculateExpensiveData();
        });
    }
    
    // Defer updates to reduce server load
    protected $queryString = [
        'search' => ['except' => ''],
    ];
    
    public function updatingSearch()
    {
        $this->resetPage();
    }
    
    // Optimize rendering
    public function render()
    {
        return view('livewire.optimized-component', [
            'items' => $this->search ? 
                Item::search($this->search)->paginate(10) :
                collect()
        ]);
    }
}

Conclusion

Both Phoenix LiveView and Laravel Livewire excel at building real-time applications:

Phoenix LiveView Advantages:

  • ⚑ Superior performance and concurrency
  • πŸ”„ Built-in real-time capabilities
  • πŸ›‘οΈ Fault tolerance and reliability
  • 🎯 Perfect for high-frequency updates

Laravel Livewire Advantages:

  • πŸš€ Rapid development and prototyping
  • πŸ—οΈ Rich ecosystem and integrations
  • πŸ‘₯ Familiar PHP patterns
  • πŸ“¦ Extensive package library

The choice depends on your specific requirements:

  • High-concurrency, performance-critical β†’ Phoenix LiveView
  • Rapid MVP, Laravel ecosystem β†’ Laravel Livewire

Both frameworks prove that modern real-time web applications don’t require complex JavaScript architectures.


Building the future of real-time collaboration, one server-side update at a time.