emaia/laravel-hotwire-turbo

Hotwire Turbo with Laravel

Maintainers

Package info

github.com/emaia/laravel-hotwire-turbo

pkg:composer/emaia/laravel-hotwire-turbo

Fund package maintenance!

Emaia

Statistics

Installs: 1 050

Dependents: 1

Suggesters: 0

Stars: 0

0.8.7 2026-04-13 21:12 UTC

README

Latest Version on Packagist GitHub Tests Action Status GitHub Code Style Action Status Total Downloads

The purpose of this package is to facilitate the use of Turbo (Hotwire) in a Laravel app.

Table of Contents

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.