emaia / laravel-hotwire-turbo
Hotwire Turbo with Laravel
Fund package maintenance!
Requires
- php: ^8.2
- illuminate/http: ^10.0|^11.0|^12.0|^13.0
- illuminate/support: ^10.0|^11.0|^12.0|^13.0
- illuminate/view: *
- spatie/laravel-package-tools: ^1.16
Requires (Dev)
- larastan/larastan: ^2.9|^3.1
- laravel/pint: ^1.14
- nunomaduro/collision: ^8.1.1
- orchestra/testbench: ^9.0|^10.0|^11.0
- orchestra/workbench: ^9.0|^10.0|^11.0
- pestphp/pest: ^3.0|^4.0
- pestphp/pest-plugin-arch: ^3.0|^4.0
- pestphp/pest-plugin-laravel: ^3.0|^4.0
- phpstan/extension-installer: ^1.3
- phpstan/phpstan-deprecation-rules: ^2.0.1
- phpstan/phpstan-phpunit: ^2.0.0
This package is auto-updated.
Last update: 2026-04-20 11:04:32 UTC
README
The purpose of this package is to facilitate the use of Turbo (Hotwire) in a Laravel app.
Table of Contents
- Installation
- Usage
- Turbo Stream Actions
- Fluent Builder
- DOM Identification
- Creating Individual Streams
- Targeting Multiple Elements
- Stream Collections
- Turbo Stream Responses
- Turbo Stream Views
- Detecting Turbo Requests
- Conditional Turbo Responses
- Custom Stream Actions
- Form Validation with Turbo Frames
- Blade Components
- Turbo Drive Blade Directives
- Turbo Drive Redirect 303
- Full Controller Example
- Configuration
- Testing
- Running Tests
Installation
composer require emaia/laravel-hotwire-turbo
Usage
Turbo Stream Actions
All Turbo 8 stream actions are supported:
| Action | Description |
|---|---|
append |
Add content after the target's existing content |
prepend |
Add content before the target's existing content |
replace |
Replace the entire target element |
update |
Update the target element's content |
remove |
Remove the target element |
after |
Insert content after the target element |
before |
Insert content before the target element |
refresh |
Trigger a page refresh |
Fluent Builder (Recommended)
The turbo_stream() helper provides a chainable API with zero imports:
return turbo_stream() ->append('messages', view('messages.item', compact('message'))) ->remove('modal') ->update('counter', '<span>42</span>');
Use withResponse() when you need custom status code or headers:
return turbo_stream() ->replace('form', view('form', ['errors' => $errors])) ->withResponse(422);
Model-Aware Targets
Pass Eloquent models directly — the target is resolved automatically via dom_id():
return turbo_stream() ->append($message, view('messages.item', compact('message'))) // target="message_15" ->remove($notification); // target="notification_8"
Morphing
Morph is a method attribute. Use it with replace or update:
// Morph the entire element (preserves event listeners, form state, etc.) turbo_stream()->replace('card', $content, method: 'morph'); // Morph only the children of the target element turbo_stream()->update('list', $content, method: 'morph');
Page Refresh
turbo_stream()->refresh(); turbo_stream()->refresh(method: 'morph', scroll: 'preserve'); turbo_stream()->refresh(requestId: 'unique-id'); // debouncing
Targeting Multiple Elements (CSS Selectors)
Use *All() methods to target multiple elements via CSS selectors:
turbo_stream() ->updateAll('.unread-count', '<span>0</span>') ->removeAll('.flash-message') ->replaceAll('.card', $content, method: 'morph');
Available: appendAll, prependAll, replaceAll, updateAll, removeAll, afterAll, beforeAll.
Conditional Chaining
turbo_stream() ->append('messages', $content) ->when($user->isAdmin(), fn ($b) => $b->update('admin_panel', $adminHtml)) ->unless($silent, fn ($b) => $b->append('notifications', $notification));
Custom Macros
The builder uses Laravel's Macroable trait, so you can register your own methods to encapsulate repetitive stream patterns. Register macros in your AppServiceProvider:
use Emaia\LaravelHotwireTurbo\TurboStreamBuilder; use Illuminate\Support\Facades\Blade; // AppServiceProvider::boot() TurboStreamBuilder::macro('closeModal', function () { return $this->update('modal'); }); TurboStreamBuilder::macro('flash', function (string $type, string $message) { return $this->append('flash-container', Blade::render( '<x-hwc::flash-message :type="$type" :message="$message" />', compact('type', 'message') )); });
Then use them fluently in your controllers:
return turbo_stream() ->replace($message, view('messages._tr', compact('message'))) ->flash('success', 'Updated successfully') ->closeModal();
DOM Identification
Generate consistent DOM IDs and CSS classes from your Eloquent models:
$message = Message::find(15); dom_id($message) // "message_15" dom_id($message, 'edit') // "edit_message_15" dom_class($message) // "message" dom_class($message, 'edit') // "edit_message" // New records (no key yet) dom_id(new Message) // "create_message" dom_id(new Message, 'new') // "new_message"
Use in Blade templates with the @domid and @domclass directives:
<div id="@domid($message)"> {{ $message->body }} </div> <div id="@domid($message, 'edit')" class="@domclass($message)"> {{-- edit form --}} </div>
Combine with streams for consistent targeting:
return turbo_stream() ->append('messages', view('messages.item', compact('message'))) ->remove(dom_id($message, 'form'));
Creating Individual Streams
Use the fluent static methods on Stream:
use Emaia\LaravelHotwireTurbo\Stream; Stream::append('messages', view('chat.message', ['message' => $message])) Stream::prepend('notifications', '<div class="alert">New!</div>') Stream::replace('user-card', view('users.card', ['user' => $user])) Stream::update('counter', '<span>42</span>') Stream::remove('modal') Stream::after('item-3', view('items.row', ['item' => $item])) Stream::before('item-3', view('items.row', ['item' => $item])) Stream::replace('profile', view('users.profile', ['user' => $user]), method: 'morph') Stream::refresh(method: 'morph', scroll: 'preserve')
All factory methods also accept models as targets:
Stream::append($message, view('chat.message', compact('message'))) Stream::remove($notification)
Or use the constructor with the Action enum:
use Emaia\LaravelHotwireTurbo\Enums\Action; use Emaia\LaravelHotwireTurbo\Stream; $stream = new Stream( action: Action::APPEND, target: 'messages', content: view('chat.message', ['message' => $message]), );
Targeting Multiple Elements (CSS Selector)
Use *All static methods or the targets constructor parameter:
// Static methods Stream::updateAll('.notification-badge', '<span>5</span>') Stream::removeAll('.flash-message') Stream::replaceAll('.card', $content, method: 'morph') // Or via constructor $stream = new Stream( action: Action::UPDATE, targets: '.notification-badge', content: '<span>5</span>', );
Stream Collections
Compose multiple streams manually when you need more control:
use Emaia\LaravelHotwireTurbo\StreamCollection; use Emaia\LaravelHotwireTurbo\Stream; $streams = new StreamCollection([ Stream::prepend('flash-container', view('components.flash', ['message' => 'Saved!'])), Stream::update('modal', ''), Stream::remove('loading-spinner'), ]); // Or build fluently $streams = StreamCollection::make() ->add(Stream::append('messages', view('chat.message', $message))) ->add(Stream::update('unread-count', '<span>0</span>')) ->add(Stream::remove('typing-indicator')); return response()->turboStream($streams);
Turbo Stream Responses
The package adds macros to Laravel's response factory. The Content-Type: text/vnd.turbo-stream.html header is set automatically:
// Single stream return response()->turboStream( Stream::replace('todo-item-1', view('todos.item', ['todo' => $todo])) ); // With custom status code return response()->turboStream($stream, 422);
Turbo Stream Views
For complex responses with multiple streams, write them in a Blade template and return with turbo_stream_view():
// Controller return turbo_stream_view('messages.streams.created', compact('message', 'count')); // Or via macro return response()->turboStreamView('messages.streams.created', compact('message', 'count'));
{{-- resources/views/messages/streams/created.blade.php --}} <x-turbo::stream action="append" target="messages"> @include('messages._message', ['message' => $message]) </x-turbo::stream> <x-turbo::stream action="update" target="message-count"> <span>{{ $count }}</span> </x-turbo::stream> <x-turbo::stream action="remove" target="new-message-form" />
Detecting Turbo Requests
// Check if the request came from any Turbo Frame if (request()->wasFromTurboFrame()) { // ... } // Check if it came from a specific Turbo Frame if (request()->wasFromTurboFrame('modal')) { // ... }
Conditional Turbo Responses
Use explicit request checks in your controllers to return Turbo Streams only when appropriate:
if (request()->wantsTurboStream()) { return turbo_stream()->remove(dom_id($message)); } return redirect()->route('messages.index');
To scope behavior to a specific Turbo Frame:
if (request()->wasFromTurboFrame('modal')) { return turbo_stream()->update('modal-content', view('messages.edit', compact('message'))); } return view('messages.edit', compact('message'));
Custom Stream Actions
Use Stream::action() for custom Turbo Stream actions with arbitrary HTML attributes:
use Emaia\LaravelHotwireTurbo\Stream; Stream::action('console-log', 'debug', '<p>Debug info</p>', [ 'data-level' => 'info', ]); // <turbo-stream action="console-log" target="debug" data-level="info">... // Via the fluent builder return turbo_stream() ->action('notification', 'alerts', '<p>Saved!</p>', ['data-timeout' => '3000']) ->remove('modal');
Form Validation with Turbo Frames
Extend TurboFormRequest to handle validation errors correctly within Turbo Frames. When validation fails, it redirects to the previous URL so the frame re-renders with errors:
use Emaia\LaravelHotwireTurbo\Http\Requests\TurboFormRequest; class UpdateProfileRequest extends TurboFormRequest { public function rules(): array { return [ 'name' => ['required', 'string', 'max:255'], 'email' => ['required', 'email'], ]; } }
Blade Components
Turbo Stream
<x-turbo::stream action="append" target="messages"> <div class="message">{{ $message->body }}</div> </x-turbo::stream> <x-turbo::stream action="remove" target="notification-{{ $id }}" /> {{-- Target multiple elements with CSS selector --}} <x-turbo::stream action="update" targets=".unread-badge"> <span>0</span> </x-turbo::stream>
Morphing
Use method="morph" on replace or update to apply morphing instead of a full DOM replacement:
{{-- Morph the entire element --}} <x-turbo::stream action="replace" method="morph" target="user-card"> @include('users.card', ['user' => $user]) </x-turbo::stream> {{-- Morph only the children --}} <x-turbo::stream action="update" method="morph" target="message-list"> @each('messages.item', $messages, 'message') </x-turbo::stream>
Page Refresh
{{-- Basic refresh --}} <x-turbo::stream action="refresh" /> {{-- Debounced refresh (multiple identical request-ids are coalesced) --}} <x-turbo::stream action="refresh" request-id="{{ $requestId }}" /> {{-- Refresh with morphing and scroll preservation --}} <x-turbo::stream action="refresh" method="morph" scroll="preserve" />
Props reference
| Prop | Description |
|---|---|
action |
Stream action — accepts string or Action enum |
target |
Target DOM id |
targets |
CSS selector to target multiple elements |
method |
morph — use morphing instead of full replacement (replace/update) |
scroll |
preserve or reset — scroll behavior for refresh |
request-id |
Debounce key for refresh actions |
Extra attributes are forwarded to the <turbo-stream> element (e.g. data-controller).
Turbo Frame
{{-- Basic frame --}} <x-turbo::frame id="user-profile"> @include('users.profile', ['user' => $user]) </x-turbo::frame> {{-- Eager-loaded frame --}} <x-turbo::frame id="inbox" src="/messages"> <p>Loading...</p> </x-turbo::frame> {{-- Lazy-loaded frame (loads when visible in viewport) --}} <x-turbo::frame id="comments" src="/posts/{{ $post->id }}/comments" loading="lazy"> <p>Loading comments...</p> </x-turbo::frame> {{-- Frame that navigates the whole page by default --}} <x-turbo::frame id="navigation" target="_top"> <a href="/dashboard">Dashboard</a> </x-turbo::frame> {{-- Disabled frame --}} <x-turbo::frame id="preview" :disabled="true"> <p>This frame won't navigate.</p> </x-turbo::frame> {{-- Morphed on page refresh (instead of a full replacement) --}} <x-turbo::frame id="feed" src="/feed" refresh="morph" /> {{-- Scroll into view after load --}} <x-turbo::frame id="results" src="/search" :autoscroll="true" autoscroll-block="start" autoscroll-behavior="smooth" /> {{-- Promote navigations to browser history --}} <x-turbo::frame id="pager" advance="advance"> <a href="?page=2">Next page</a> </x-turbo::frame> {{-- Recursive frame --}} <x-turbo::frame id="recursive" src="/frame" recurse="composer" />
Props reference
| Prop | Description |
|---|---|
id |
Frame identifier (required) |
src |
URL to load content from (eager by default) |
loading |
eager (default) or lazy |
target |
Default navigation target — use _top to navigate the whole page |
disabled |
Prevents all navigation |
refresh |
morph — use morphing when the frame reloads on page refresh |
autoscroll |
Scroll the frame into view after loading |
autoscroll-block |
Vertical alignment: end (default), start, center, nearest |
autoscroll-behavior |
Scroll animation: auto (default) or smooth |
advance |
advance or replace — promote navigations to browser history |
recurse |
Frame id to recurse into when extracting content |
Extra attributes are forwarded to the <turbo-frame> element (e.g. class, data-controller).
Turbo Drive Blade Directives
Loading Turbo via CDN
Add Turbo to your layout without a build step:
<head> @turboCdn </head>
This outputs:
<script type="module" src="https://cdn.jsdelivr.net/npm/@hotwired/turbo@latest/dist/turbo.es2017-esm.min.js"></script>
Meta Tag Directives
Control Turbo Drive behavior in your layout's <head>:
<head> @turboNocache @turboNoPreview @turboRefreshMethod('morph') @turboRefreshScroll('preserve') @turboVisitControl('reload') @turboRoot('/app') @viewTransition('same-origin') @turboPrefetch('false') </head>
| Directive | Output |
|---|---|
@turboCdn |
<script type="module" src="...turbo.es2017-esm.min.js"></script> |
@turboNocache |
<meta name="turbo-cache-control" content="no-cache"> |
@turboNoPreview |
<meta name="turbo-cache-control" content="no-preview"> |
@turboRefreshMethod('morph') |
<meta name="turbo-refresh-method" content="morph"> |
@turboRefreshScroll('preserve') |
<meta name="turbo-refresh-scroll" content="preserve"> |
@turboVisitControl('reload') |
<meta name="turbo-visit-control" content="reload"> |
@turboRoot('/app') |
<meta name="turbo-root" content="/app"> |
@viewTransition('same-origin') |
<meta name="view-transition" content="same-origin"> |
@turboPrefetch('false') |
<meta name="turbo-prefetch" content="false"> |
Turbo Drive Redirect 303
Turbo Drive requires form submission redirects to use HTTP status 303 (See Other) instead of the default 302. Without this, Turbo Drive will not follow the redirect after a form submission.
The package automatically registers a global middleware that converts all redirects to 303 when the request comes from Turbo (either Turbo Drive or Turbo Frame). This is enabled by default and requires no setup.
To disable the automatic middleware and register it manually on specific routes:
// config/turbo.php 'auto_redirect_303' => false,
// bootstrap/app.php or route groups use Emaia\LaravelHotwireTurbo\Http\Middleware\TurboMiddleware; Route::middleware(TurboMiddleware::class)->group(function () { Route::post('/posts', [PostController::class, 'store']); });
Full Controller Example
class MessageController extends Controller { public function store(Request $request) { $message = Message::create($request->validated()); if (request()->wantsTurboStream()) { return turbo_stream() ->append('messages', view('messages.item', compact('message'))) ->update('message-form', view('messages.form')) ->update('message-count', '<span>' . Message::count() . '</span>'); } return redirect()->route('messages.index'); } public function destroy(Message $message) { $message->delete(); if (request()->wantsTurboStream()) { return turbo_stream()->remove($message); } return redirect()->route('messages.index'); } public function edit(Message $message) { if (request()->wantsTurboStream() && request()->wasFromTurboFrame('modal')) { return turbo_stream()->update('modal-content', view('messages.edit', compact('message'))); } return view('messages.edit', compact('message')); } }
Configuration
// config/turbo.php return [ // Namespaces stripped when generating DOM IDs from models. // Customize if your models live outside App\Models\. 'model_namespaces' => ['App\\Models\\', 'App\\'], // Automatically convert redirects to 303 for Turbo visits. // Set to false to register the TurboMiddleware manually. 'auto_redirect_303' => true, ];
For example, if your models are in Domain\Billing\Models\:
'model_namespaces' => ['Domain\\Billing\\Models\\', 'App\\Models\\', 'App\\'],
Testing
The package provides testing utilities for asserting Turbo Stream responses.
Setup
Add the InteractsWithTurbo trait to your test class:
use Emaia\LaravelHotwireTurbo\Testing\InteractsWithTurbo; class MessageControllerTest extends TestCase { use InteractsWithTurbo; }
Making Turbo Requests
// Send request with Turbo Stream Accept header $this->turbo()->post('/messages', ['body' => 'Hello']); // Send request from a specific Turbo Frame $this->fromTurboFrame('modal')->get('/messages/create'); // Combine both $this->turbo()->fromTurboFrame('modal')->post('/messages', $data);
Asserting Responses
// Assert the response is a Turbo Stream $this->turbo() ->post('/messages', ['body' => 'Hello']) ->assertTurboStream(); // Assert stream count and match specific streams $this->turbo() ->delete("/messages/{$message->id}") ->assertTurboStream(fn ($streams) => $streams ->has(1) ->hasTurboStream(fn ($stream) => $stream ->where('action', 'remove') ->where('target', dom_id($message)) ) ); // Assert content inside a stream $this->turbo() ->post('/messages', ['body' => 'Hello']) ->assertTurboStream(fn ($streams) => $streams ->hasTurboStream(fn ($stream) => $stream ->where('action', 'append') ->where('target', 'messages') ->see('Hello') ) ); // Assert response is NOT a Turbo Stream $this->get('/messages')->assertNotTurboStream();
Running Tests
composer test
Changelog
Please see CHANGELOG for more information on what has changed recently.
Contributing
Please see CONTRIBUTING for details.
Security Vulnerabilities
Please review our security policy on how to report security vulnerabilities.
Credits
License
The MIT License (MIT). Please see License File for more information.