emaia / laravel-hotwire-turbo
Hotwire Turbo with Laravel
Fund package maintenance!
Requires
- php: ^8.2
- illuminate/http: ^11.0|^12.0|^13.0
- illuminate/support: ^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.29.1
- nunomaduro/collision: ^8.9.4
- 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
This package is auto-updated.
Last update: 2026-06-08 18:18:14 UTC
README
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
- When to use this package
- Installation
- Usage
- Turbo Stream Actions
- Fluent Builder
- DOM Identification
- Creating Individual Streams
- Targeting Multiple Elements
- Stream Collections
- Turbo Stream Responses
- Turbo Stream Views
- Detecting Turbo Requests
- Conditional Turbo Responses
- Custom Stream Actions
- Form Validation with Turbo Frames
- Blade Components
- Turbo Drive Blade Directives
- Turbo Drive Redirect 303
- Exceptions
- Full Controller Example
- Configuration
- Testing
- Running Tests
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.