PHPackages                             emaia/laravel-hotwire-turbo - PHPackages - PHPackages  [Skip to content](#main-content)[PHPackages](/)[Directory](/)[Categories](/categories)[Trending](/trending)[Leaderboard](/leaderboard)[Changelog](/changelog)[Analyze](/analyze)[Collections](/collections)[Log in](/login)[Sign up](/register)

1. [Directory](/)
2. /
3. [Utility &amp; Helpers](/categories/utility)
4. /
5. emaia/laravel-hotwire-turbo

ActiveLibrary[Utility &amp; Helpers](/categories/utility)

emaia/laravel-hotwire-turbo
===========================

Hotwire Turbo with Laravel

0.9.3(4w ago)12.6k1MITPHPPHP ^8.2CI passing

Since Feb 7Pushed 4w agoCompare

[ Source](https://github.com/emaia/laravel-hotwire-turbo)[ Packagist](https://packagist.org/packages/emaia/laravel-hotwire-turbo)[ Docs](https://github.com/emaia/laravel-hotwire-turbo)[ GitHub Sponsors](https://github.com/Emaia)[ RSS](/packages/emaia-laravel-hotwire-turbo/feed)WikiDiscussions main Synced 3w ago

READMEChangelog (10)Dependencies (45)Versions (40)Used By (1)

Hotwire Turbo with Laravel!
===========================

[](#hotwire-turbo-with-laravel)

[![Latest Version on Packagist](https://camo.githubusercontent.com/56b87e7f8200aeb83f6dd432b6c267295c87824ef6e8435f026ab2bac5eb48bd/68747470733a2f2f696d672e736869656c64732e696f2f7061636b61676973742f762f656d6169612f6c61726176656c2d686f74776972652d747572626f2e7376673f7374796c653d666c61742d737175617265)](https://packagist.org/packages/emaia/laravel-hotwire-turbo)[![GitHub Tests Action Status](https://camo.githubusercontent.com/219174c57403a179c127c84ef437077aba34e758c246ea6984410e1da5e5d937/68747470733a2f2f696d672e736869656c64732e696f2f6769746875622f616374696f6e732f776f726b666c6f772f7374617475732f656d6169612f6c61726176656c2d686f74776972652d747572626f2f72756e2d74657374732e796d6c3f6272616e63683d6d61696e266c6162656c3d7465737473267374796c653d666c61742d737175617265)](https://github.com/emaia/laravel-hotwire-turbo/actions?query=workflow%3Arun-tests+branch%3Amain)[![GitHub Code Style Action Status](https://camo.githubusercontent.com/837076d5b0850387a53023402f46e25e606e9870f8eaaabba7c11c09135e0011/68747470733a2f2f696d672e736869656c64732e696f2f6769746875622f616374696f6e732f776f726b666c6f772f7374617475732f656d6169612f6c61726176656c2d686f74776972652d747572626f2f6669782d7068702d636f64652d7374796c652d6973737565732e796d6c3f6272616e63683d6d61696e266c6162656c3d636f64652532307374796c65267374796c653d666c61742d737175617265)](https://github.com/emaia/laravel-hotwire-turbo/actions?query=workflow%3A%22Fix+PHP+code+style+issues%22+branch%3Amain)[![Total Downloads](https://camo.githubusercontent.com/46e281ecb8fe64bcdd33e291833c1a113da9167ee7e838e8af046cb9166d87d6/68747470733a2f2f696d672e736869656c64732e696f2f7061636b61676973742f64742f656d6169612f6c61726176656c2d686f74776972652d747572626f2e7376673f7374796c653d666c61742d737175617265)](https://packagist.org/packages/emaia/laravel-hotwire-turbo)

**The thin server-side foundation for [Turbo](https://turbo.hotwired.dev/) ([Hotwire](https://hotwired.dev/)) 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
------------------------

[](#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`](https://github.com/hotwired-laravel/turbo-laravel) if…Use [`emaia/laravel-hotwire`](https://github.com/emaia/laravel-hotwire) if…You want a thin server-side layer over Turbo with full controlYou want Rails-like conventions and automatic model broadcastingYou want UI components, Stimulus controllers, and generators on topYou bring your own UI components and JavaScript scaffoldingYou target Hotwire Native (iOS/Android)You want a batteries-included Hotwire stackYou'll integrate broadcasting via Laravel Echo manually if neededYou 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
-----------------

[](#table-of-contents)

- [When to use this package](#when-to-use-this-package)
- [Installation](#installation)
- [Usage](#usage)
    - [Turbo Stream Actions](#turbo-stream-actions)
    - [Fluent Builder](#fluent-builder-recommended)
        - [Model-Aware Targets](#model-aware-targets)
        - [Morphing](#morphing)
        - [Page Refresh](#page-refresh)
        - [Targeting Multiple Elements (CSS Selectors)](#targeting-multiple-elements-css-selectors)
        - [Conditional Chaining](#conditional-chaining)
        - [Attaching Views with view() / partial()](#attaching-views-with-view--partial)
        - [Escaping User-Supplied Content](#escaping-user-supplied-content)
        - [Custom Macros](#custom-macros)
        - [Echoing Streams in Blade](#echoing-streams-in-blade)
    - [DOM Identification](#dom-identification)
    - [Creating Individual Streams](#creating-individual-streams)
    - [Targeting Multiple Elements](#targeting-multiple-elements-css-selector)
    - [Stream Collections](#stream-collections)
    - [Turbo Stream Responses](#turbo-stream-responses)
    - [Turbo Stream Views](#turbo-stream-views)
    - [Detecting Turbo Requests](#detecting-turbo-requests)
    - [Conditional Turbo Responses](#conditional-turbo-responses)
    - [Custom Stream Actions](#custom-stream-actions)
    - [Form Validation with Turbo Frames](#form-validation-with-turbo-frames)
    - [Blade Components](#blade-components)
        - [Turbo Stream](#turbo-stream)
        - [Turbo Frame](#turbo-frame)
        - [Turbo Stream Source](#turbo-stream-source)
    - [Turbo Drive Blade Directives](#turbo-drive-blade-directives)
        - [Loading Turbo via CDN](#loading-turbo-via-cdn)
        - [Meta Tag Directives](#meta-tag-directives)
        - [Refreshes With](#refreshes-with)
    - [Turbo Drive Redirect 303](#turbo-drive-redirect-303)
    - [Exceptions](#exceptions)
    - [Full Controller Example](#full-controller-example)
- [Configuration](#configuration)
- [Testing](#testing)
- [Running Tests](#running-tests)

Installation
------------

[](#installation)

```
composer require emaia/laravel-hotwire-turbo
```

Usage
-----

[](#usage)

### Turbo Stream Actions

[](#turbo-stream-actions)

All Turbo 8 stream actions are supported:

ActionDescription`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)

[](#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', '42');
```

Use `withResponse()` when you need custom status code or headers:

```
return turbo_stream()
    ->replace('form', view('form', ['errors' => $errors]))
    ->withResponse(422);
```

#### Model-Aware Targets

[](#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

[](#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

[](#page-refresh)

```
turbo_stream()->refresh();
turbo_stream()->refresh(method: 'morph', scroll: 'preserve');
turbo_stream()->refresh(requestId: 'unique-id');  // debouncing
```

#### Targeting Multiple Elements (CSS Selectors)

[](#targeting-multiple-elements-css-selectors)

Use `*All()` methods to target multiple elements via CSS selectors:

```
turbo_stream()
    ->updateAll('.unread-count', '0')
    ->removeAll('.flash-message')
    ->replaceAll('.card', $content, method: 'morph');
```

Available: `appendAll`, `prependAll`, `replaceAll`, `updateAll`, `removeAll`, `afterAll`, `beforeAll`.

#### Conditional Chaining

[](#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()`

[](#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

[](#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

[](#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(
        '',
        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

[](#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

[](#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:

```

    {{ $message->body }}

    {{-- edit form --}}

```

Combine with streams for consistent targeting:

```
return turbo_stream()
    ->append('messages', view('messages.item', compact('message')))
    ->remove(dom_id($message, 'form'));
```

### Creating Individual Streams

[](#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', 'New!')
Stream::replace('user-card', view('users.card', ['user' => $user]))
Stream::update('counter', '42')
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)

[](#targeting-multiple-elements-css-selector)

Use `*All` static methods or the `targets` constructor parameter:

```
// Static methods
Stream::updateAll('.notification-badge', '5')
Stream::removeAll('.flash-message')
Stream::replaceAll('.card', $content, method: 'morph')

// Or via constructor
$stream = new Stream(
    action: Action::UPDATE,
    targets: '.notification-badge',
    content: '5',
);
```

### Stream Collections

[](#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', '0'))
    ->add(Stream::remove('typing-indicator'));

return response()->turboStream($streams);
```

### Turbo Stream Responses

[](#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

[](#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 --}}

    @include('messages._message', ['message' => $message])

    {{ $count }}

```

### Detecting Turbo Requests

[](#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

[](#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

[](#custom-stream-actions)

Use `Stream::action()` for custom Turbo Stream actions with arbitrary HTML attributes:

```
use Emaia\LaravelHotwireTurbo\Stream;

Stream::action('console-log', 'debug', 'Debug info', [
    'data-level' => 'info',
]);
// ...

// Via the fluent builder
return turbo_stream()
    ->action('notification', 'alerts', 'Saved!', ['data-timeout' => '3000'])
    ->remove('modal');
```

### Form Validation with Turbo Frames

[](#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

[](#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:

```

        @turboFrameSrc

        Save

```

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

[](#redirect-source-priority)

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

PrioritySourceNotes1`_turbo_frame_src` inputSet by `@turboFrameSrc` — deterministic, server-side2`X-Turbo-Frame-Src` headerOptional, can be set by client-side JS if desired3`session('_previous.url')`Laravel session fallback for simple cases4`RuntimeException`Explicit error when all sources failExternal 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

[](#blade-components)

#### Turbo Stream

[](#turbo-stream)

```

    {{ $message->body }}

{{-- Target multiple elements with CSS selector --}}

    0

```

##### Morphing

[](#morphing-1)

Use `method="morph"` on `replace` or `update` to apply [morphing](https://turbo.hotwired.dev/handbook/page_refreshes) instead of a full DOM replacement:

```
{{-- Morph the entire element --}}

    @include('users.card', ['user' => $user])

{{-- Morph only the children --}}

    @each('messages.item', $messages, 'message')

```

##### Page Refresh

[](#page-refresh-1)

```
{{-- Basic refresh --}}

{{-- Debounced refresh (multiple identical request-ids are coalesced) --}}

{{-- Refresh with morphing and scroll preservation --}}

```

##### Props reference

[](#props-reference)

PropDescription`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 actionsExtra attributes are forwarded to the `` element (e.g. `data-controller`).

#### Turbo Frame

[](#turbo-frame)

```
{{-- Basic frame --}}

    @include('users.profile', ['user' => $user])

{{-- Eager-loaded frame --}}

    Loading...

{{-- Lazy-loaded frame (loads when visible in viewport) --}}

    Loading comments...

{{-- Frame that navigates the whole page by default --}}

    Dashboard

{{-- Disabled frame --}}

    This frame won't navigate.

{{-- Morphed on page refresh (instead of a full replacement) --}}

{{-- Scroll into view after load --}}

{{-- Promote navigations to browser history --}}

    Next page

{{-- Recursive frame --}}

{{-- Model-based id (resolves to dom_id($message)) --}}

    @include('messages._item', ['message' => $message])

```

##### Props reference

[](#props-reference-1)

PropDescription`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 contentExtra attributes are forwarded to the `` element (e.g. `class`, `data-controller`).

#### Turbo Stream Source

[](#turbo-stream-source)

Connect to a Server-Sent Events or WebSocket endpoint that pushes `` messages:

```
{{-- SSE --}}

{{-- WebSocket --}}

```

PropDescription`src`Endpoint URL (required)Extra attributes are forwarded to the `` element.

### Turbo Drive Blade Directives

[](#turbo-drive-blade-directives)

#### Loading Turbo via CDN

[](#loading-turbo-via-cdn)

Add Turbo to your layout without a build step:

```

    @turboCdn

```

This outputs:

```

```

#### Meta Tag Directives

[](#meta-tag-directives)

Control Turbo Drive behavior in your layout's ``:

```

    @turboNocache
    @turboNoPreview
    @turboRefreshMethod('morph')
    @turboRefreshScroll('preserve')
    @turboVisitControl('reload')
    @turboRoot('/app')
    @viewTransition('same-origin')
    @turboPrefetch('false')

```

DirectiveOutput`@turboCdn````@turboNocache````@turboNoPreview````@turboRefreshMethod('morph')````@turboRefreshScroll('preserve')````@turboVisitControl('reload')````@turboRoot('/app')````@viewTransition('same-origin')````@turboPrefetch('false')```#### Refreshes With

[](#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:

```

```

Outputs:

```

```

PropDescription`method``morph` — use morphing on page refreshes`scroll``preserve` or `reset` — scroll behaviorThe individual directives (`@turboRefreshMethod`, `@turboRefreshScroll`) remain available if you prefer them.

### Turbo Drive Redirect 303

[](#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

[](#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

[](#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', '' . Message::count() . '');
        }

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

[](#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
-------

[](#testing)

The package provides testing utilities for asserting Turbo Stream responses.

### Setup

[](#setup)

Add the `InteractsWithTurbo` trait to your test class:

```
use Emaia\LaravelHotwireTurbo\Testing\InteractsWithTurbo;

class MessageControllerTest extends TestCase
{
    use InteractsWithTurbo;
}
```

### Making Turbo Requests

[](#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

[](#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
-------------

[](#running-tests)

```
composer test
```

Changelog
---------

[](#changelog)

Please see [CHANGELOG](CHANGELOG.md) for more information on what has changed recently.

Contributing
------------

[](#contributing)

Please see [CONTRIBUTING](CONTRIBUTING.md) for details.

Security Vulnerabilities
------------------------

[](#security-vulnerabilities)

Please review [our security policy](../../security/policy) on how to report security vulnerabilities.

Credits
-------

[](#credits)

- [Emaia](https://github.com/emaia)
- [All Contributors](../../contributors)

License
-------

[](#license)

The MIT License (MIT). Please see [License File](LICENSE.md) for more information.

###  Health Score

48

—

FairBetter than 94% of packages

Maintenance94

Actively maintained with recent releases

Popularity24

Limited adoption so far

Community11

Small or concentrated contributor base

Maturity53

Maturing project, gaining track record

 Bus Factor1

Top contributor holds 84.9% of commits — single point of failure

How is this calculated?**Maintenance (25%)** — Last commit recency, latest release date, and issue-to-star ratio. Uses a 2-year decay window.

**Popularity (30%)** — Total and monthly downloads, GitHub stars, and forks. Logarithmic scaling prevents top-heavy scores.

**Community (15%)** — Contributors, dependents, forks, watchers, and maintainers. Measures real ecosystem engagement.

**Maturity (30%)** — Project age, version count, PHP version support, and release stability.

###  Release Activity

Cadence

Every ~14 days

Recently: every ~4 days

Total

35

Last Release

29d ago

### Community

Maintainers

![](https://www.gravatar.com/avatar/fbb3604953e8a0809951105762dc0fc735d1254c29558e989934591117948c84?d=identicon)[emaia](/maintainers/emaia)

---

Top Contributors

[![emaia](https://avatars.githubusercontent.com/u/131877?v=4)](https://github.com/emaia "emaia (90 commits)")[![dependabot[bot]](https://avatars.githubusercontent.com/in/29110?v=4)](https://github.com/dependabot[bot] "dependabot[bot] (10 commits)")[![github-actions[bot]](https://avatars.githubusercontent.com/in/15368?v=4)](https://github.com/github-actions[bot] "github-actions[bot] (6 commits)")

---

Tags

hotwirehotwiredhotwired-laravelhotwired-turbolaravelturboturbo-driveturbo-streamturbo-streamslaravelturbohotwirelaravel-hotwire-turbo

###  Code Quality

TestsPest

Static AnalysisPHPStan

Code StyleLaravel Pint

### Embed Badge

![Health badge](/badges/emaia-laravel-hotwire-turbo/health.svg)

```
[![Health](https://phpackages.com/badges/emaia-laravel-hotwire-turbo/health.svg)](https://phpackages.com/packages/emaia-laravel-hotwire-turbo)
```

###  Alternatives

[psalm/plugin-laravel

Psalm plugin for Laravel

3345.1M337](/packages/psalm-plugin-laravel)[yajra/laravel-datatables-oracle

jQuery DataTables API for Laravel

4.9k35.3M364](/packages/yajra-laravel-datatables-oracle)[spatie/laravel-responsecache

Speed up a Laravel application by caching the entire response

2.8k8.7M64](/packages/spatie-laravel-responsecache)[defstudio/telegraph

A laravel facade to interact with Telegram Bots

815320.5k3](/packages/defstudio-telegraph)[ralphjsmit/laravel-glide

Auto-magically generate responsive images from static image files.

4923.6k5](/packages/ralphjsmit-laravel-glide)[simplestats-io/laravel-client

Analytics for Laravel. Track visitors, registrations, and payments. Discover which channels actually drive revenue, not just traffic. Server-side, GDPR compliant, ad-blocker proof.

5019.3k](/packages/simplestats-io-laravel-client)

PHPackages © 2026

[Directory](/)[Categories](/categories)[Trending](/trending)[Changelog](/changelog)[Analyze](/analyze)
