Laravel + Turbo/Hotwire: Building Rails-Style Realtime Applications
Introduction
Hotwire is a toolkit from the Rails team consisting of Turbo and Stimulus, allowing you to build fast, realtime web applications without single-page application frameworks. Laravel can integrate Hotwire seamlessly.
What's in Hotwire?
- Turbo Drive: Automatically AJAX-ify all links and forms
- Turbo Frames: Update parts of the page without reload
- Turbo Streams: Realtime updates via WebSockets
- Stimulus: Lightweight JavaScript framework for interactions
Comparison with HTMX
| Feature | Hotwire | HTMX |
|---|---|---|
| Learning curve | Medium | Low |
| WebSocket support | Turbo Streams | Needs extension |
| JavaScript needs | Stimulus | Vanilla/Alpine |
| Rails ecosystem | ✅ Official | ❌ |
| Laravel ecosystem | Package | Package |
| Philosophy | HTML-over-wire | HTML-over-wire |
Both are excellent choices - Hotwire fits if you prefer Rails-style, HTMX is lighter.
Installation
Step 1: Install NPM Packages
npm install @hotwired/turbo @hotwired/stimulus
Step 2: Setup JavaScript
// resources/js/app.js
import * as Turbo from "@hotwired/turbo"
import { Application } from "@hotwired/stimulus"
// Start Turbo
Turbo.start()
// Start Stimulus
const application = Application.start()
// Configure Stimulus
application.debug = process.env.NODE_ENV === 'development'
window.Stimulus = application
// Auto-load controllers
const controllers = import.meta.glob('./controllers/*_controller.js', { eager: true })
for (const path in controllers) {
const name = path.match(/\.\/controllers\/(.+)_controller\.js$/)[1]
application.register(name.replace(/_/g, '-'), controllers[path].default)
}
Step 3: Laravel Backend Support
Install Turbo support package:
composer require tonysm/turbo-laravel
php artisan turbo:install
// config/turbo-laravel.php
return [
'queue' => false, // Use queue for broadcasts
'queue_connection' => env('TURBO_QUEUE_CONNECTION', 'sync'),
];
Turbo Drive: Zero-JavaScript Navigation
Turbo Drive automatically intercepts all clicks and form submissions, replacing them with fetch requests:
<!-- No changes needed, links are automatically AJAX -->
<a href="/posts">View Posts</a>
<!-- Forms too -->
<form action="/posts" method="POST">
@csrf
<input name="title">
<button type="submit">Create</button>
</form>
Controlling Turbo Drive
<!-- Disable for a link -->
<a href="/logout" data-turbo="false">Logout</a>
<!-- Disable for a form -->
<form data-turbo="false">...</form>
<!-- Only disable specific method -->
<a href="/external-link" data-turbo-method="post">External</a>
Progress Bar
/* resources/css/app.css */
.turbo-progress-bar {
height: 3px;
background-color: #4f46e5;
}
Prefetch
<!-- Prefetch on hover -->
<a href="/posts/1" data-turbo-prefetch="true">Post 1</a>
<!-- Disable prefetch -->
<meta name="turbo-prefetch" content="false">
Turbo Frames: Scoped Navigation
Turbo Frames allow updating only parts of the page:
Basic Frame
{{-- resources/views/posts/index.blade.php --}}
@extends('layouts.app')
@section('content')
<div class="container">
<h1>Posts</h1>
{{-- This frame will be updated separately --}}
<turbo-frame id="posts-list">
@include('posts.partials.list')
</turbo-frame>
<aside>
{{-- Content outside frame is not affected --}}
<h2>Sidebar</h2>
</aside>
</div>
@endsection
{{-- resources/views/posts/partials/list.blade.php --}}
<turbo-frame id="posts-list">
@foreach($posts as $post)
<article>
<h2>
{{-- Click will only update this frame --}}
<a href="{{ route('posts.show', $post) }}">{{ $post->title }}</a>
</h2>
</article>
@endforeach
{{ $posts->links() }} {{-- Pagination also updates within frame --}}
</turbo-frame>
Lazy Loading Frames
{{-- Load content when frame becomes visible --}}
<turbo-frame id="comments" src="{{ route('posts.comments', $post) }}" loading="lazy">
<div class="animate-pulse">Loading comments...</div>
</turbo-frame>
// Controller returns partial
public function comments(Post $post)
{
$comments = $post->comments()->latest()->get();
// Turbo Frames only need the matching HTML part
return view('posts.partials.comments', compact('comments'));
}
Frame Actions
{{-- Breaking out of frame --}}
<turbo-frame id="modal">
<div class="modal-content">
{{-- This link will navigate the entire page --}}
<a href="/posts" data-turbo-frame="_top">Close and go to posts</a>
{{-- Or target another frame --}}
<a href="/notifications" data-turbo-frame="notifications">
Load notifications
</a>
</div>
</turbo-frame>
Modal Pattern
{{-- Button to open modal --}}
<a href="{{ route('posts.create') }}"
data-turbo-frame="modal"
class="btn btn-primary">
Create Post
</a>
{{-- Modal container --}}
<turbo-frame id="modal" class="hidden">
{{-- Content will be loaded here --}}
</turbo-frame>
{{-- resources/views/posts/create.blade.php --}}
<turbo-frame id="modal" class="block">
<div class="fixed inset-0 bg-black/50 flex items-center justify-center">
<div class="bg-white rounded-lg p-6 max-w-lg w-full">
<h2>Create Post</h2>
<form action="{{ route('posts.store') }}" method="POST">
@csrf
<input type="text" name="title" class="w-full border p-2 mb-4">
<textarea name="content" class="w-full border p-2 mb-4"></textarea>
<div class="flex gap-2">
<a href="{{ route('posts.index') }}"
data-turbo-frame="modal">
Cancel
</a>
<button type="submit" class="btn btn-primary">Create</button>
</div>
</form>
</div>
</div>
</turbo-frame>
Turbo Streams: Realtime Updates
Turbo Streams allow the server to send HTML updates to the client via HTTP response or WebSockets.
Stream Actions
<!-- Append: add to end -->
<turbo-stream action="append" target="posts">
<template>
<article id="post_1">New post</article>
</template>
</turbo-stream>
<!-- Prepend: add to beginning -->
<turbo-stream action="prepend" target="posts">
<template>
<article id="post_1">New post</article>
</template>
</turbo-stream>
<!-- Replace: replace element -->
<turbo-stream action="replace" target="post_1">
<template>
<article id="post_1">Updated post</article>
</template>
</turbo-stream>
<!-- Update: replace innerHTML -->
<turbo-stream action="update" target="post_1">
<template>
Updated content only
</template>
</turbo-stream>
<!-- Remove: delete element -->
<turbo-stream action="remove" target="post_1"></turbo-stream>
<!-- Before/After: insert relative -->
<turbo-stream action="before" target="post_2">
<template>
<article id="post_1">Insert before post_2</article>
</template>
</turbo-stream>
Laravel Integration
// app/Http/Controllers/PostController.php
use Tonysm\TurboLaravel\Http\TurboResponseFactory;
class PostController extends Controller
{
public function store(Request $request)
{
$post = Post::create($request->validated());
if ($request->wantsTurboStream()) {
return TurboResponseFactory::makeStream()
->append('posts', view('posts.partials.post', ['post' => $post]));
}
return redirect()->route('posts.index')
->with('success', 'Post created!');
}
public function destroy(Post $post)
{
$post->delete();
if (request()->wantsTurboStream()) {
return TurboResponseFactory::makeStream()
->remove("post_{$post->id}");
}
return redirect()->route('posts.index');
}
}
Multiple Streams
public function store(Request $request)
{
$post = Post::create($request->validated());
if ($request->wantsTurboStream()) {
return TurboResponseFactory::makeStream()
// Add post to list
->prepend('posts', view('posts.partials.post', ['post' => $post]))
// Update counter
->update('posts-count', view('posts.partials.count', [
'count' => Post::count()
]))
// Clear form
->update('post-form', view('posts.partials.empty-form'));
}
return redirect()->route('posts.index');
}
WebSocket Broadcasts
Combine with Laravel Broadcasting for realtime updates:
// app/Models/Post.php
use Tonysm\TurboLaravel\Models\Broadcasts;
class Post extends Model
{
use Broadcasts;
protected $broadcastsTo = ['posts'];
public function broadcastAppend()
{
return [
'target' => 'posts',
'partial' => 'posts.partials.post',
];
}
}
// app/Providers/EventServiceProvider.php
use Tonysm\TurboLaravel\Events\TurboStreamBroadcast;
protected $listen = [
TurboStreamBroadcast::class => [
// Broadcasting handled automatically
],
];
{{-- Subscribe in view --}}
@turboStream('posts')
// resources/js/app.js
import { connectStreamSource } from "@hotwired/turbo"
// Connect to Echo channel
if (window.Echo) {
const channel = window.Echo.channel('posts')
connectStreamSource(channel)
}
Stimulus: JavaScript Sprinkling
Stimulus adds JavaScript behaviors in an organized way:
Basic Controller
// resources/js/controllers/dropdown_controller.js
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["menu"]
static values = {
open: { type: Boolean, default: false }
}
toggle() {
this.openValue = !this.openValue
}
openValueChanged() {
this.menuTarget.classList.toggle("hidden", !this.openValue)
}
// Click outside to close
close(event) {
if (!this.element.contains(event.target)) {
this.openValue = false
}
}
connect() {
document.addEventListener("click", this.close.bind(this))
}
disconnect() {
document.removeEventListener("click", this.close.bind(this))
}
}
<div data-controller="dropdown">
<button data-action="dropdown#toggle">
Menu
</button>
<div data-dropdown-target="menu" class="hidden">
<a href="#">Item 1</a>
<a href="#">Item 2</a>
</div>
</div>
Form Controller
// resources/js/controllers/form_controller.js
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["submit", "field"]
static values = {
submitText: { type: String, default: "Submit" },
loadingText: { type: String, default: "Saving..." }
}
connect() {
this.validate()
}
validate() {
const isValid = this.fieldTargets.every(field => field.checkValidity())
this.submitTarget.disabled = !isValid
}
submit(event) {
this.submitTarget.disabled = true
this.submitTarget.textContent = this.loadingTextValue
}
// Reset after Turbo submission
reset() {
this.submitTarget.disabled = false
this.submitTarget.textContent = this.submitTextValue
}
}
<form data-controller="form"
data-action="turbo:submit-end->form#reset"
data-form-submit-text-value="Create Post"
data-form-loading-text-value="Creating...">
<input type="text"
name="title"
required
data-form-target="field"
data-action="input->form#validate">
<button type="submit"
data-form-target="submit"
data-action="form#submit">
Create Post
</button>
</form>
Search Controller
// resources/js/controllers/search_controller.js
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["input", "results"]
static values = {
url: String,
delay: { type: Number, default: 300 }
}
search() {
clearTimeout(this.timeout)
this.timeout = setTimeout(() => {
this.performSearch()
}, this.delayValue)
}
async performSearch() {
const query = this.inputTarget.value
if (query.length < 2) {
this.resultsTarget.innerHTML = ''
return
}
const response = await fetch(`${this.urlValue}?q=${encodeURIComponent(query)}`, {
headers: {
'Accept': 'text/vnd.turbo-stream.html',
'X-Requested-With': 'XMLHttpRequest'
}
})
if (response.ok) {
const html = await response.text()
Turbo.renderStreamMessage(html)
}
}
}
<div data-controller="search"
data-search-url-value="{{ route('search') }}"
data-search-delay-value="500">
<input type="text"
data-search-target="input"
data-action="input->search#search"
placeholder="Search...">
<turbo-frame id="search-results" data-search-target="results">
{{-- Results will be loaded here --}}
</turbo-frame>
</div>
Notification Controller
// resources/js/controllers/notification_controller.js
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static values = {
timeout: { type: Number, default: 5000 }
}
connect() {
this.scheduleRemoval()
}
scheduleRemoval() {
setTimeout(() => {
this.dismiss()
}, this.timeoutValue)
}
dismiss() {
this.element.classList.add('opacity-0', 'transition-opacity')
setTimeout(() => {
this.element.remove()
}, 300)
}
}
@if(session('success'))
<div data-controller="notification"
data-notification-timeout-value="3000"
class="fixed top-4 right-4 bg-green-500 text-white px-4 py-2 rounded">
{{ session('success') }}
<button data-action="notification#dismiss">×</button>
</div>
@endif
Form Validation with Turbo
Server-side Validation
// Controller
public function store(Request $request)
{
$validated = $request->validate([
'title' => 'required|min:3|max:255',
'content' => 'required',
]);
$post = Post::create($validated);
if ($request->wantsTurboStream()) {
return TurboResponseFactory::makeStream()
->prepend('posts', view('posts.partials.post', ['post' => $post]))
->replace('post-form', view('posts.partials.form'));
}
return redirect()->route('posts.index');
}
Laravel automatically returns 422 with errors when validation fails. Turbo will handle this:
{{-- Form will be automatically re-rendered with errors --}}
<turbo-frame id="post-form">
<form action="{{ route('posts.store') }}" method="POST">
@csrf
<div class="mb-4">
<input type="text"
name="title"
value="{{ old('title') }}"
class="@error('title') border-red-500 @enderror">
@error('title')
<p class="text-red-500 text-sm">{{ $message }}</p>
@enderror
</div>
<div class="mb-4">
<textarea name="content"
class="@error('content') border-red-500 @enderror">{{ old('content') }}</textarea>
@error('content')
<p class="text-red-500 text-sm">{{ $message }}</p>
@enderror
</div>
<button type="submit">Create</button>
</form>
</turbo-frame>
Complete Example: Task Manager
Routes
// routes/web.php
Route::resource('tasks', TaskController::class);
Route::post('tasks/{task}/toggle', [TaskController::class, 'toggle'])->name('tasks.toggle');
Controller
// app/Http/Controllers/TaskController.php
namespace App\Http\Controllers;
use App\Models\Task;
use Illuminate\Http\Request;
use Tonysm\TurboLaravel\Http\TurboResponseFactory;
class TaskController extends Controller
{
public function index()
{
$tasks = Task::latest()->get();
return view('tasks.index', compact('tasks'));
}
public function store(Request $request)
{
$task = Task::create($request->validate([
'title' => 'required|max:255'
]));
if ($request->wantsTurboStream()) {
return TurboResponseFactory::makeStream()
->prepend('task-list', view('tasks.partials.task', compact('task')))
->update('task-form', view('tasks.partials.form'));
}
return redirect()->route('tasks.index');
}
public function toggle(Task $task)
{
$task->update(['completed' => !$task->completed]);
if (request()->wantsTurboStream()) {
return TurboResponseFactory::makeStream()
->replace("task_{$task->id}", view('tasks.partials.task', compact('task')));
}
return back();
}
public function destroy(Task $task)
{
$task->delete();
if (request()->wantsTurboStream()) {
return TurboResponseFactory::makeStream()
->remove("task_{$task->id}");
}
return redirect()->route('tasks.index');
}
}
Views
{{-- resources/views/tasks/index.blade.php --}}
@extends('layouts.app')
@section('content')
<div class="max-w-2xl mx-auto py-8">
<h1 class="text-2xl font-bold mb-6">Tasks</h1>
{{-- Form --}}
<turbo-frame id="task-form">
@include('tasks.partials.form')
</turbo-frame>
{{-- Task List --}}
<div id="task-list" class="mt-6 space-y-2">
@foreach($tasks as $task)
@include('tasks.partials.task')
@endforeach
</div>
</div>
@endsection
{{-- resources/views/tasks/partials/form.blade.php --}}
<turbo-frame id="task-form">
<form action="{{ route('tasks.store') }}"
method="POST"
class="flex gap-2">
@csrf
<input type="text"
name="title"
placeholder="What needs to be done?"
class="flex-1 border rounded px-3 py-2 @error('title') border-red-500 @enderror"
value="{{ old('title') }}"
autofocus>
<button type="submit" class="bg-blue-500 text-white px-4 py-2 rounded">
Add
</button>
</form>
@error('title')
<p class="text-red-500 text-sm mt-1">{{ $message }}</p>
@enderror
</turbo-frame>
{{-- resources/views/tasks/partials/task.blade.php --}}
<div id="task_{{ $task->id }}"
class="flex items-center justify-between p-3 bg-white rounded shadow"
data-controller="task">
<div class="flex items-center gap-3">
<form action="{{ route('tasks.toggle', $task) }}" method="POST">
@csrf
<button type="submit"
class="w-6 h-6 rounded-full border-2 flex items-center justify-center
{{ $task->completed ? 'bg-green-500 border-green-500' : 'border-gray-300' }}">
@if($task->completed)
<svg class="w-4 h-4 text-white" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/>
</svg>
@endif
</button>
</form>
<span class="{{ $task->completed ? 'line-through text-gray-400' : '' }}">
{{ $task->title }}
</span>
</div>
<form action="{{ route('tasks.destroy', $task) }}" method="POST">
@csrf
@method('DELETE')
<button type="submit"
class="text-red-500 hover:text-red-700"
data-turbo-confirm="Delete this task?">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</form>
</div>
Conclusion
Laravel + Hotwire brings a Rails-like development experience - fast, simple, server-rendered:
- Turbo Drive: Fast navigation without code
- Turbo Frames: Partial page updates
- Turbo Streams: Realtime updates
- Stimulus: Organized JavaScript
This is an excellent choice if you want:
- Less JavaScript
- Server-side rendering
- Progressive enhancement
- Rails-style development