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: 3 674

Dependents: 1

Suggesters: 0

Stars: 1

Open Issues: 0


README

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

The thin server-side foundation for Turbo (Hotwire) in Laravel. A focused set of server-side primitives — Stream builder, frame helpers, DOM id resolution, request detection, validation handling, and test utilities — without imposing UI components, broadcasting, or JavaScript scaffolding.

When to use this package

The Hotwire ecosystem for Laravel has three packages with overlapping but distinct goals. Pick the one that matches your project:

Use this package if… Use hotwired-laravel/turbo-laravel if… Use emaia/laravel-hotwire if…
You want a thin server-side layer over Turbo with full control You want Rails-like conventions and automatic model broadcasting You want UI components, Stimulus controllers, and generators on top
You bring your own UI components and JavaScript scaffolding You target Hotwire Native (iOS/Android) You want a batteries-included Hotwire stack
You'll integrate broadcasting via Laravel Echo manually if needed You want auto-broadcast via Eloquent observers (depends on this package as foundation)

This package keeps a narrow scope on purpose: broadcasting, Hotwire Native, JavaScript scaffolding, and UI components are out of scope here — they're covered by the sibling packages above.

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));

Attaching Views with view() / partial()

Use view() (or its alias partial()) to attach a Blade view to the most recently added stream. This is an alternative to passing view(...) inline — useful when you want target and content on separate lines:

return turbo_stream()
    ->append('messages')->view('messages._item', compact('message'))
    ->update('counter')->partial('counters._badge', ['count' => $count]);

Equivalent to:

return turbo_stream()
    ->append('messages', view('messages._item', compact('message')))
    ->update('counter', view('counters._badge', ['count' => $count]));

Calling view() before any stream is added throws a LogicException.

Escaping User-Supplied Content

By default, content is rendered as raw HTML. When the content is a user-supplied string that may contain HTML, call escape() to apply e() before render:

return turbo_stream()
    ->update('greeting', $user->name)->escape()
    ->append('messages', view('messages._item', compact('message')));  // not escaped

escape() applies to the most recently added stream only and has no effect on content already rendered through view() or partial().

Custom Macros

Both TurboStreamBuilder and Stream use Laravel's Macroable trait, so you can register your own methods to encapsulate repetitive stream patterns. Register macros in your AppServiceProvider:

use Emaia\LaravelHotwireTurbo\Stream;
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')
    ));
});

// Macros also work on the Stream factory for one-off streams.
Stream::macro('confetti', function (string $target) {
    return Stream::action('confetti', $target, '', ['data-duration' => '2000']);
});

Then use them fluently in your controllers:

return turbo_stream()
    ->replace($message, view('messages._tr', compact('message')))
    ->flash('success', 'Updated successfully')
    ->closeModal();

return response()->turboStream(Stream::confetti('party'));

Echoing Streams in Blade

Stream, StreamCollection and TurboStreamBuilder all implement Htmlable, so you can render them directly in Blade without escaping:

{{ turbo_stream()->append('messages', view('messages.item', compact('message'))) }}

{{ Stream::remove($notification) }}

This is useful when composing Turbo Stream views by hand or returning streams from view composers.

DOM Identification

Generate consistent DOM IDs and CSS classes from your Eloquent models — or any object exposing a getKey() method or a public $id property (DTOs, readonly classes, etc.):

$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)

Individual streams also expose the same view(), partial() and escape() helpers:

Stream::append('messages')->view('messages._item', compact('message'));
Stream::update('greeting', $user->name)->escape();

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')) {
    // ...
}

// Read the X-Turbo-Request-Id header (set by Turbo Drive on every visit).
// Useful as a debounce key for refresh streams.
$requestId = request()->turboRequestId();    // string|null

return turbo_stream()->refresh(requestId: $requestId);

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, the request redirects back to the page that rendered the frame so the frame re-renders with error messages.

use Emaia\LaravelHotwireTurbo\Http\Requests\TurboFormRequest;

class UpdateProfileRequest extends TurboFormRequest
{
    public function rules(): array
    {
        return [
            'name' => ['required', 'string', 'max:255'],
            'email' => ['required', 'email'],
        ];
    }
}

Explicit Frame Source URL

Add the @turboFrameSrc directive inside your Turbo Frame forms for deterministic redirects that don't rely on session state or browser headers:

<turbo-frame id="profile-form" src="{{ route('profile.edit') }}">
    <form method="POST" action="{{ route('profile.update') }}">
        @turboFrameSrc

        <input name="name" required />
        <button>Save</button>
    </form>
</turbo-frame>

This renders a hidden input with the current page URL, ensuring the redirect target is always correct — even with lazy-loaded frames or multiple browser tabs.

Redirect Source Priority

When validation fails inside a Turbo Frame, the redirect URL is resolved in this order:

Priority Source Notes
1 _turbo_frame_src input Set by @turboFrameSrc — deterministic, server-side
2 X-Turbo-Frame-Src header Optional, can be set by client-side JS if desired
3 session('_previous.url') Laravel session fallback for simple cases
4 RuntimeException Explicit error when all sources fail

External URLs from levels 1 and 2 are validated against trusted hosts. Untrusted URLs are rejected (redirects fall back to /) to prevent open redirect attacks.

If no source URL can be resolved, a RuntimeException is thrown with a clear message asking the developer to add the @turboFrameSrc directive to the form.

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" />

{{-- Model-based id (resolves to dom_id($message)) --}}
<x-turbo::frame :id="$message">
    @include('messages._item', ['message' => $message])
</x-turbo::frame>
Props reference
Prop Description
id Frame identifier (required). Accepts a string or any object resolved via dom_id() (Eloquent model, DTO with getKey()/$id)
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 Stream Source

Connect to a Server-Sent Events or WebSocket endpoint that pushes <turbo-stream> messages:

{{-- SSE --}}
<x-turbo::stream-source src="/messages/stream" />

{{-- WebSocket --}}
<x-turbo::stream-source src="wss://example.com/cable" />
Prop Description
src Endpoint URL (required)

Extra attributes are forwarded to the <turbo-stream-source> element.

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">

Refreshes With

<x-turbo::refreshes-with> packs the two most common page-refresh meta tags into a single component. Both props are optional — only the ones you pass are emitted:

<head>
    <x-turbo::refreshes-with method="morph" scroll="preserve" />
</head>

Outputs:

<meta name="turbo-refresh-method" content="morph">
<meta name="turbo-refresh-scroll" content="preserve">
Prop Description
method morph — use morphing on page refreshes
scroll preserve or reset — scroll behavior

The individual directives (@turboRefreshMethod, @turboRefreshScroll) remain available if you prefer them.

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']);
});

Exceptions

When a Turbo Stream response cannot be built — missing target, non-stream item in a StreamCollection, or a builder method called before any stream was added — the package throws Emaia\LaravelHotwireTurbo\Exceptions\TurboStreamResponseFailedException.

The exception extends InvalidArgumentException (and therefore LogicException), so existing catch blocks keep working. For finer-grained handling, catch the typed exception directly:

use Emaia\LaravelHotwireTurbo\Exceptions\TurboStreamResponseFailedException;

try {
    return turbo_stream()->view('messages._item', compact('message'));
} catch (TurboStreamResponseFailedException $e) {
    // No stream was added before view(), missing target, etc.
    report($e);
    return back();
}

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

Publish the config file to customize the defaults:

php artisan vendor:publish --tag="turbo-config"
// 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,

    // Extra hosts trusted for TurboFormRequest redirects. The current
    // request host and APP_URL host are always trusted; anything else
    // falls back to "/". Use for staging domains or reverse proxies.
    'trusted_redirect_hosts' => [],
];

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 as a plain (non-Turbo) browser visit — useful to assert
// the same endpoint still returns full-page HTML.
$this->withoutTurbo()->get('/messages')->assertSee('Inbox');

// 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();

// Shorthand assertions
$this->turbo()
    ->post('/messages', ['body' => 'Hello'])
    ->assertTurboStreamCount(2)
    ->assertTurboStreamHas('append', 'messages')
    ->assertTurboStreamHas('append', 'messages', 'Hello');  // content optional

// Or use the callback form for full control
$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 (callback form, when matching by `targets=` CSS selector)
$this->turbo()
    ->post('/messages', ['body' => 'Hello'])
    ->assertTurboStream(fn ($streams) => $streams
        ->hasTurboStream(fn ($stream) => $stream
            ->where('action', 'update')
            ->where('targets', '.badge')
            ->see('Hello')
        )
    );

// Assert response is NOT a Turbo Stream
$this->get('/messages')->assertNotTurboStream();

The shorthand assertTurboStreamHas matches the target attribute (DOM id). For streams using CSS selectors (targets=".css"), use the callback form shown above.

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.