hotwired-laravel/turbo-laravel
Turbo Laravel brings Hotwire Turbo to Laravel, delivering SPA-like speed with server-rendered HTML. It integrates Turbo Drive, Frames, and Streams with Blade helpers and page morphing, so you can build fast, reactive interfaces without writing custom JavaScript.
Install and wire-up (5 minutes):
composer require hotwired-laravel/turbo-laravel:^2.6
php artisan turbo:install # publishes config, layout scaffolding, JS entrypoint
npm install && npm run dev
Then in resources/js/app.js:
import '@hotwired/turbo-laravel'
Two non-negotiables before anything works:
<meta name="csrf-token" content="{{ csrf_token() }}"> in your layout <head> — turbo:install adds it, but custom layouts often lose it and every non-GET request 419s.@hotwired/turbo must be loaded via the package's JS package (it re-exports/initialises Turbo), not imported twice.Where to look first:
config/turbo.php — the only file you'll actually edit; skim it before writing code so you don't hardcode what's configurable.HotwiredLaravel\TurboLaravel\Facades\Turbo facade — it's thin, and reading it tells you the whole API surface faster than the docs.vendor/hotwired-laravel/turbo-laravel/src/Http/ for the response classes and testing assertions — these define the contract you'll be asserting against.First real use case: take an existing PostsController@store that does return redirect()->route('posts.index') and make the new row appear without a page reload:
public function store(StorePostRequest $request)
{
$post = Post::create($request->validated());
return Turbo::stream('append', target: 'posts', partial: 'posts._post', data: compact('post'))
->toResponse($request);
}
<div id="posts">@foreach ($posts as $post) @include('posts._post') @endforeach</div>
That single change — partial + dom_id-consistent target — is the pattern you'll repeat 90% of the time.
Pick one ID convention on day one. Use the package's dom_id() helper everywhere (Blade and streams) so targets can't drift:
<div id="{{ dom_id($post) }}"> … </div>
Stream responses belong on non-GET requests. For navigations use frames, for mutations use streams. Mixing them (returning a stream to a Turbo Drive visit) silently does nothing — Turbo only accepts text/vnd.turbo-stream.html on form submissions and data-turbo-stream requests.
Frames for in-place navigation, streams for multi-region updates. A lazy frame is the cheapest win for pagination/tabs:
<x-turbo-frame id="post_42" src="{{ route('posts.show', $post) }}" loading="lazy">
<p>Loading…</p>
</x-turbo-frame>
Request-type branching. Controllers that serve both HTML and frame/stream clients should branch explicitly rather than guessing:
if (Turbo::isTurboFrameRequest()) {
return view('posts._show', compact('post')); // frame-only, no layout
}
return view('posts.show', compact('post'));
Validation errors as streams. Override failedValidation() in FormRequests (or render the ValidationException from the exception handler) to return a stream that replaces the error container and re-renders the form — status 422 + text/vnd.turbo-stream.html is what Turbo expects:
protected function failedValidation(Validator $validator)
{
if (Turbo::isTurboRequest()) {
throw (new ValidationException($validator))
->response(function ($request, $response) use ($validator) {
return Turbo::stream('replace', target: 'form_errors', partial: 'shared._errors', data: ['errors' => $validator->errors()])
->toResponse($request)
->setStatusCode(422);
});
}
parent::failedValidation($validator);
}
Redirects inside stream responses must go through the facade (Turbo::redirect(...), Turbo::refresh()), not redirect(). A plain RedirectResponse embedded where the client expects a stream is not followed.
Testing is first-class — use it. The package ships Turbo Stream assertions, so a stream regression is a red test rather than a manual click-through:
$response->assertOk()
->assertTurboStream()
->assertTurboStreamSee('New comment')
->assertTurboStreamMissing('Nothing here');
Native/mobile shell. Turbo::isTurboNativeVisit() plus the native redirect helpers let one controller serve both the web and a Turbo Native wrapper; keep that branch at the top of your auth/redirect paths so device flows don't fight web flows.
Broadcasting. Streams can be pushed over Echo for multi-user updates; treat that as a phase-two concern — the same partial renders both paths, and queued workers must be running or updates appear only for the actor.
id matches the frame.data-turbo-temporary (Turbo 8) or clear them in a turbo:before-cache listener. This is the #1 "is the package broken?" report.Turbo::redirect() for stream-context redirects.data-turbo="false" on the link/form, otherwise the response gets swallowed into the page.Content-Type first, then the target id in the DOM, then the network tab for Turbo-Frame request headers. Nine times out of ten it's an id typo or a layout leaking into a frame.assertJson; and if you're comparing rendered markup, normalise whitespace or the test becomes brittle.config/turbo.php under version control once edited; the Turbo facade is container-resolved, so a custom implementation can be bound in a service provider for multi-tenant or shell-specific redirect logic, and the facade is trivially mockable in unit tests.^2.6 on Laravel 10–13. On a fresh L13 app, remember middleware registration moved to bootstrap/app.php; the turbo:install output generated against older skeletons may need that file hand-edited.^2.6) and run the JS build in the pipeline — server-side stream tests pass happily while the front-end package is unmounted, which produces "it works in tests, not in the browser" confusion.How can I help you explore Laravel packages today?