Laravel + Turbo/Hotwire: Building Rails-Style Realtime Applications

· 15 min read

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?

  1. Turbo Drive: Automatically AJAX-ify all links and forms
  2. Turbo Frames: Update parts of the page without reload
  3. Turbo Streams: Realtime updates via WebSockets
  4. 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>
{{-- 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">&times;</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

References

Comments