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: 40

Dependents: 0

Suggesters: 0

Stars: 0

0.4.1 2026-04-05 11:58 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.

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.