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.