hotwired/turbo-laravel
Turbo Laravel integrates Hotwired Turbo with Laravel, offering Artisan install commands, Blade components and directives for Turbo Frames, Streams, and native apps, plus helpers that make your Laravel responses Turbo-compatible.
Install and wire up (10 minutes):
composer require hotwired-laravel/turbo-laravel
php artisan turbo:install
turbo:install does three things you need to verify, not trust:
config/turbo.php.HandleTurboRequests middleware on the web group. On Laravel 10 this lands in app/Http/Kernel.php; on Laravel 11+ the installer will point you at bootstrap/app.php — confirm it's actually there, because without it redirects and Turbo-Frame headers behave wrong and you'll lose an hour.Version note: 2.6.0 added Laravel 13.x compatibility (the only change since 2.5.1), so the package now spans the Laravel 10 → 13 range. That's a compatibility bump, not a feature release — no code changes required on upgrade, but it does mean the installer's Laravel-11+ path is now the mainstream one, so read it carefully.
Then bring in the front end — the package does not ship Turbo itself:
npm install @hotwired/turbo
// resources/js/app.js
import '@hotwired/turbo'
Make sure your layout has the CSRF meta tag (@csrf isn't enough — Turbo needs <meta name="csrf-token" content="{{ csrf_token() }}">) and that assets are versioned, otherwise Turbo's cache serves stale HTML.
Where to look first:
config/turbo.php — one screen, read it all. It controls broadcasting, native path config, and the Turbo-Frame/Stream behaviors.HotwiredLaravel\TurboLaravel\Facades\Turbo — this is the API surface you'll live in (Turbo::stream(), Turbo::redirectTo(), Turbo::visit(), Turbo::broadcast(), plus the isTurbo* request checks).<x-turbo-frame> and <x-turbo-stream> are thin wrappers around the real tags; read the source so you know what attributes pass through.First use case to build: lazy-load a turbo-frame, then make a form inside it submit and answer with a turbo-stream. That's the whole mental model in one feature.
{{-- dashboard.blade.php --}}
<x-turbo-frame id="recent-orders" src="{{ route('orders.recent') }}" lazy>
<div class="animate-pulse">Loading orders…</div>
</x-turbo-frame>
// OrderController
public function recent()
{
return view('orders._recent', ['orders' => Order::latest()->take(10)->get()]);
}
The partial must render a <turbo-frame id="recent-orders"> at its root, and the ID must match exactly. That's the first gotcha you'll hit, so hit it deliberately.
1. Frame-aware controllers (the daily bread). Don't build a parallel endpoint for every frame. One action, branch on the request:
public function index()
{
$posts = Post::latest()->paginate();
if (Turbo::isTurboFrameRequest()) {
return view('posts._list', compact('posts')); // slim, no layout
}
return view('posts.index', compact('posts')); // full page
}
This keeps URLs shareable (a frame src is a real URL you can open in a browser) and avoids duplicate routing. Wrap the common case in a small helper or a respondWithTurbo() trait method on your base controller.
2. Turbo Streams from the controller. The package returns a real StreamedResponse with Content-Type: text/vnd.turbo-stream.html. The fluent builder is the pattern to learn:
use HotwiredLaravel\TurboLaravel\Facades\Turbo;
public function store(StorePostRequest $request)
{
$post = Post::create($request->validated());
return Turbo::stream()
->action('append')
->target('posts')
->partial('posts._post', ['post' => $post]);
}
Chain multiple targets for multi-region updates (append the row, replace the counter, clear the form). Reach for <x-turbo-stream> in Blade only when you need streams emitted from a full page render.
3. Redirects after non-GET. Turbo requires 303 See Other so the browser follows with a GET. The middleware converts normal redirect() calls for you, but inside frames that isn't enough — use the explicit helper when you want to break out of a frame:
return Turbo::redirectTo(route('posts.show', $post)); // 303, frame-safe
return Turbo::visit(route('posts.index')); // full Turbo visit
return Turbo::refresh(); // re-render current page
Rule of thumb: form submits and destructive actions → Turbo::redirectTo(); anything where the user should land somewhere new outside the frame → Turbo::visit().
4. Validation errors inside frames. Return the re-rendered frame with a 422 status — Turbo (7.1+) accepts it as a valid frame response and does not discard the page:
throw ValidationException::withMessages([...]); // Laravel gives 422 automatically
…provided your FormRequest fails normally and the error view still renders the frame. Don't return back() from a frame; you'll get a redirect loop or a nested-frame render.
5. Broadcasting as the "live updates" layer. The package's real differentiator is Turbo::broadcast():
Turbo::broadcast($post)
->action('append')
->target('posts')
->partial('posts._post', ['post' => $post])
->toOthers(); // skips the originating tab via X-Socket-ID
This complements broadcastOn()/broadcastWith() on your Eloquent models. toOthers() only works if Laravel Echo is configured on the client (it sets the X-Socket-ID header). If Echo isn't installed, drop toOthers() — otherwise it silently no-ops and you'll chase a ghost.
6. Turbo Native (if you're building a hybrid app). The package exposes path-configuration helpers that emit the JSON your iOS/Android shells consume, plus request helpers to detect native visits. Use it to declare default vs modal vs replace_root contexts by URL pattern, and add a turbo_native_app guard so web-only routes render normally for browsers.
7. Compose with the Hotwire stack, not against it. Alpine for local UI state, Stimulus for behavior, Turbo for navigation. Keep controllers thin: streams are just responses, so extract stream-building into app/Turbo/ or a TurboStreams service class once you have more than three shapes.
redirect() from a POST makes Turbo re-issue the request as a GET to the redirect target, which usually works but loses flash semantics and breaks for PUT/DELETE. Always use Turbo::redirectTo() (303) for non-GET.append, replace, and update all target by ID. Duplicate IDs (e.g., a full page that also renders the partial inside a frame) cause writes to the wrong element. Namespace your frame/target IDs (post-42, posts, posts-count) and never reuse them across regions.fastcgi_buffering/gzip. Send X-Accel-Buffering: no, disable output_buffering, and make sure no middleware compresses or buffers the body. php artisan serve won't reproduce it; test behind your real web server before shipping.data-turbo-temporary, add turbo-cache-control: no-cache headers where needed, and clean up in turbo:before-cache.data-turbo="false" is your escape hatch — file downloads, third-party embeds, and legacy forms that must do a full reload. It's a smaller hammer than disabling Turbo for the whole page.config/turbo.php broadcasting enabled. Failures here are silent — no exception, just no DOM update. Add a Log::info in the broadcast callback when debugging.turbo:install can duplicate the middleware entry. Check Kernel.php/bootstrap/app.php by hand after upgrades.assertHeader('Turbo-Frame', 'posts') for frame requests (set it manually in tests), assertSee/assertStreamedContent for the stream body, and assert the 303 status for redirects. A Turbo::fake()-style shortcut is not something to rely on — check the package's Testing namespace for whatever macros your version ships before writing custom assertions.dependents: 0 mean you are still the QA team for edge cases. Pin the version in composer.json, read the changelog before bumping, and don't expect rapid fixes for weird Octane interactions. Also note: a framework-compat release like 2.6.0 is usually untested against your specific Laravel minor's new middleware/bootstrap defaults, so run your full suite after bumping — especially if you subclass HandleTurboRequests.Turbo facade is swappable and the middleware is a normal class — subclass it if you need custom redirect rules (e.g., always breaking out of frames on a certain route prefix) rather than sprinkling conditionals in controllers.How can I help you explore Laravel packages today?