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
- dev-main
- 0.4.1
- v0.4.0
- v0.3.1
- v0.3.0
- v0.2.0
- v0.1.9
- v0.1.8
- v0.1.7
- v0.1.6
- v0.1.5
- v0.1.4
- v0.1.3
- v0.1.2
- v0.1.1
- v0.1
- dev-dependabot/github_actions/dependabot/fetch-metadata-3.0.0
- dev-dependabot/github_actions/ramsey/composer-install-4
- dev-dependabot/github_actions/actions/checkout-6
- dev-dependabot/github_actions/stefanzweifel/git-auto-commit-action-7
This package is auto-updated.
Last update: 2026-04-05 11:59:03 UTC
README
The purpose of this package is to facilitate the use of Turbo (Hotwire) in a Laravel app.
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 |
morph |
Morph the target element to the new content |
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>') ->respond();
With custom status code:
return turbo_stream() ->replace('form', view('form', ['errors' => $errors])) ->respond(422);
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')) ->respond();
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::morph('profile', view('users.profile', ['user' => $user])) Stream::refresh()
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 targets to target multiple DOM elements via CSS selector:
$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" />
Conditional Turbo Responses
Turbo::if() eliminates the most common if/else pattern in Turbo controllers. It returns the stream when the request wants Turbo, or the fallback otherwise:
use Emaia\LaravelHotwireTurbo\Turbo; return Turbo::if( stream: turbo_stream()->remove(dom_id($message))->respond(), fallback: redirect()->route('messages.index'), );
You can also pass a StreamInterface directly (it will be wrapped in a TurboResponse automatically):
return Turbo::if( stream: turbo_stream()->remove(dom_id($message)), fallback: redirect()->route('messages.index'), );
Scope the response to a specific Turbo Frame with the frame parameter. The stream is returned only when the request both wants Turbo and comes from the matching frame:
return Turbo::if( stream: turbo_stream()->remove('modal-content'), fallback: redirect()->route('messages.index'), frame: 'modal', );
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') ->respond();
Detecting Turbo Requests
if (request()->wantsTurboStream()) { return turbo_stream() ->replace('todo-1', view('todos.item', ['todo' => $todo])) ->respond(); } return redirect()->back();
// 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')) { // ... }
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>
Turbo Frame
{{-- Basic frame --}} <x-turbo::frame id="user-profile"> @include('users.profile', ['user' => $user]) </x-turbo::frame> {{-- Lazy-loaded frame --}} <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 --}} <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>
Turbo Drive Blade Directives
Control Turbo Drive behavior in your layout's <head>:
<head> @turboNocache @turboNoPreview @turboRefreshMethod('morph') @turboRefreshScroll('preserve') </head>
| Directive | Output |
|---|---|
@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"> |
Full Controller Example
use Emaia\LaravelHotwireTurbo\Turbo; class MessageController extends Controller { public function store(Request $request) { $message = Message::create($request->validated()); return Turbo::if( stream: turbo_stream() ->append('messages', view('messages.item', compact('message'))) ->update('message-form', view('messages.form')) ->update('message-count', '<span>' . Message::count() . '</span>') ->respond(), fallback: redirect()->route('messages.index'), ); } public function destroy(Message $message) { $message->delete(); return Turbo::if( stream: turbo_stream()->remove(dom_id($message)), fallback: redirect()->route('messages.index'), ); } public function edit(Message $message) { return Turbo::if( stream: turbo_stream()->update('modal-content', view('messages.edit', compact('message'))), fallback: view('messages.edit', compact('message')), frame: 'modal', ); } }
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.