Laravel + Turbo/Hotwire: Xây Dựng Ứng Dụng Realtime Kiểu Rails
Giới Thiệu
Hotwire là bộ công cụ từ team Rails gồm Turbo và Stimulus, cho phép xây dựng ứng dụng web nhanh, realtime mà không cần single-page application framework. Laravel có thể tích hợp Hotwire một cách mượt mà.
Hotwire Gồm Những Gì?
- Turbo Drive: Tự động AJAX-ify tất cả links và forms
- Turbo Frames: Update một phần trang mà không reload
- Turbo Streams: Realtime updates qua WebSockets
- Stimulus: Lightweight JavaScript framework cho interactions
So Sánh Với HTMX
| Tính năng | Hotwire | HTMX |
|---|---|---|
| Learning curve | Trung bình | Thấp |
| WebSocket support | Turbo Streams | Cần extension |
| JavaScript needs | Stimulus | Vanilla/Alpine |
| Rails ecosystem | ✅ Chính thức | ❌ |
| Laravel ecosystem | Package | Package |
| Philosophy | HTML-over-wire | HTML-over-wire |
Cả hai đều là lựa chọn tốt - Hotwire phù hợp nếu bạn thích Rails-style, HTMX nhẹ hơn.
Cài Đặt
Bước 1: Cài NPM Packages
npm install @hotwired/turbo @hotwired/stimulus
Bước 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)
}
Bước 3: Laravel Backend Support
Cài package hỗ trợ Turbo:
composer require tonysm/turbo-laravel
php artisan turbo:install
// config/turbo-laravel.php
return [
'queue' => false, // Sử dụng queue cho broadcasts
'queue_connection' => env('TURBO_QUEUE_CONNECTION', 'sync'),
];
Turbo Drive: Zero-JavaScript Navigation
Turbo Drive tự động intercept tất cả clicks và form submissions, thay thế bằng fetch requests:
<!-- Không cần thay đổi gì, links tự động AJAX -->
<a href="/posts">View Posts</a>
<!-- Forms cũng vậy -->
<form action="/posts" method="POST">
@csrf
<input name="title">
<button type="submit">Create</button>
</form>
Kiểm Soát Turbo Drive
<!-- Disable cho một link -->
<a href="/logout" data-turbo="false">Logout</a>
<!-- Disable cho một form -->
<form data-turbo="false">...</form>
<!-- Chỉ disable method cụ thể -->
<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 khi 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 cho phép chỉ update một phần của trang:
Basic Frame
{{-- resources/views/posts/index.blade.php --}}
@extends('layouts.app')
@section('content')
<div class="container">
<h1>Posts</h1>
{{-- Frame này sẽ được update riêng --}}
<turbo-frame id="posts-list">
@include('posts.partials.list')
</turbo-frame>
<aside>
{{-- Content ngoài frame không bị ảnh hưởng --}}
<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 sẽ chỉ update frame này --}}
<a href="{{ route('posts.show', $post) }}">{{ $post->title }}</a>
</h2>
</article>
@endforeach
{{ $posts->links() }} {{-- Pagination cũng chỉ update trong frame --}}
</turbo-frame>
Lazy Loading Frames
{{-- Load content khi frame visible --}}
<turbo-frame id="comments" src="{{ route('posts.comments', $post) }}" loading="lazy">
<div class="animate-pulse">Loading comments...</div>
</turbo-frame>
// Controller trả về partial
public function comments(Post $post)
{
$comments = $post->comments()->latest()->get();
// Turbo Frames chỉ cần phần HTML phù hợp
return view('posts.partials.comments', compact('comments'));
}
Frame Actions
{{-- Breaking out of frame --}}
<turbo-frame id="modal">
<div class="modal-content">
{{-- Link này sẽ navigate toàn bộ page --}}
<a href="/posts" data-turbo-frame="_top">Close and go to posts</a>
{{-- Hoặc target frame khác --}}
<a href="/notifications" data-turbo-frame="notifications">
Load notifications
</a>
</div>
</turbo-frame>
Modal Pattern
{{-- Button mở 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 sẽ được load vào đây --}}
</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 cho phép server gửi HTML updates đến client qua HTTP response hoặc WebSockets.
Stream Actions
<!-- Append: thêm vào cuối -->
<turbo-stream action="append" target="posts">
<template>
<article id="post_1">New post</article>
</template>
</turbo-stream>
<!-- Prepend: thêm vào đầu -->
<turbo-stream action="prepend" target="posts">
<template>
<article id="post_1">New post</article>
</template>
</turbo-stream>
<!-- Replace: thay thế element -->
<turbo-stream action="replace" target="post_1">
<template>
<article id="post_1">Updated post</article>
</template>
</turbo-stream>
<!-- Update: thay thế innerHTML -->
<turbo-stream action="update" target="post_1">
<template>
Updated content only
</template>
</turbo-stream>
<!-- Remove: xóa 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()
// Thêm post vào 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
Kết hợp với Laravel Broadcasting để 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 trong 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 thêm JavaScript behaviors theo cách có tổ chức:
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 với 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 tự động trả về 422 với errors khi validation fails. Turbo sẽ handle điều này:
{{-- Form sẽ tự động được re-render với 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>
Kết Luận
Laravel + Hotwire mang lại trải nghiệm development giống Rails - nhanh, đơn giản, server-rendered:
- Turbo Drive: Navigation nhanh không cần code
- Turbo Frames: Partial page updates
- Turbo Streams: Realtime updates
- Stimulus: Organized JavaScript
Đây là lựa chọn tuyệt vời nếu bạn muốn:
- Ít JavaScript hơn
- Server-side rendering
- Progressive enhancement
- Rails-style development