This package gives you a set of conventions to make the most out of Hotwire in Laravel (inspired by the turbo-rails gem). There is a companion application that shows how to use the package and its conventions in your application.
You may install the package via composer:
composer require tonysm/turbo-laravel
In case you're NOT using Jetstream, you may publish the asset files with:
php artisan turbo:install
You may also use Turbo Laravel with Jetstream if you use the Livewire stack. If you want to do so, you may want to publish the assets using the --jet
flag:
php artisan turbo:install --jet
The turbo:install command will make sure you have the needed JS libs on your package.json
file and will also publish some JS files to your application. By default, it will add @hotwired/turbo
to your package.json
file and publish another custom HTML tag to integrate Turbo with Laravel Echo in the resources/js/elements
folder. With the --jet
flag, it will also add a couple needed bridge libs to make sure you can use Hotwire combined with Jetstream and Livewire, these are:
- Alpine Turbo Bridge, needed so Alpine.js works nicely; and
- Livewire Turbo Plugin needed so Livewire works nicely. This one will be added to your Jetstream layouts as script tags fetching from a CDN (both
app.blade.php
andguest.blade.php
)
You may also optionally install Stimulus on top of this all by passing --stimulus
flag to the turbo:install
command:
php artisan turbo:install --jet --stimulus
Stimulus is optional because you may want to stick with Alpine.js (or both /shrug).
The package ships with a middleware that applies some conventions on your redirects, specially around how failed validations are handled automatically by Laravel. Read more about this in the Conventions section of the documentation.
You may add the middleware to the "web" route group on your HTTP Kernel, like so:
namespace App\Http;
use Illuminate\Foundation\Http\Kernel as HttpKernel;
class Kernel extends HttpKernel
{
protected $middlewareGroups = [
'web' => [
// ...
\Tonysm\TurboLaravel\Http\Middleware\TurboMiddleware::class,
],
];
}
Keep reading the documentation to have a full picture on how you can make the most out of the technique.
It's highly recommended reading the Turbo Handbook. Out of everything Turbo provides, it's Turbo Streams that benefits the most from a tight integration with Laravel. We can generate Turbo Streams from your models and either return them from HTTP responses or broadcast your model changes over WebSockets using Laravel Echo.
- Conventions
- Overview
- Notes on Turbo Drive and Turbo Frames
- Blade Directives and Helper Functions
- Turbo Streams
- Custom Turbo Stream Views
- Broadcasting Turbo Streams Over WebSockets with Laravel Echo
- Validation Response Redirects
- Turbo Native
- Testing Helpers
- Closing Notes
First of all, none of these conventions are mandatory. Feel free to pick the ones you like and also add your own. With that out of the way, here's a list of some conventions that I find helpful:
- You may want to use resource routes for most things (
posts.index
,posts.store
, etc) - You may want to split your views into smaller chunks or partials (small portions of HTML for specific fragments), such as
comments/_comment.blade.php
that displays a comment resource, orcomments/_form.blade.php
for the form to either create/update comments. This will allow you to reuse these partials in Turbo Streams - Your model's partial (such as the
comments/_comment.blade.php
for aComment
model, for example) may only rely on having a$comment
instance passed to it. When broadcasting your model changes and generating the Turbo Streams in background, the package will pass the model instance using the model's basename in camelCase to that partial - although you can fully control this behavior - You may use the model's Fully Qualified Class Name, or FQCN for short, on your Broadcasting Channel authorizations with a wildcard such as
.{id}
, such asApp.Models.Comment.{comment}
for aComment
model living inApp\\Models\\
- the wildcard's name doesn't matter
In the Overview section below you will see how to override most of the default behaviors, if you want to.
Once the assets are compiled, you will have a couple custom HTML tags that you may annotate your Turbo Frames and Turbo Streams with. This is vanilla Hotwire. Again, it's recommended to read the Turbo Handbook. Once you understand how these few pieces work together, the challenge will be in decomposing your UI to work as you want them to.
To keep it short, Turbo Drive will turn links and form submissions into AJAX requests and will replace the page with the response. That's useful when you want to navigate to another page entirely.
If you want some elements to persist across these navigations, you may annotate these elements with a DOM ID and add the data-turbo-permanent
custom attribute to them. As long as the response also contains an element with the same ID and data-turbo-permanent
, Turbo will not touch it.
Sometimes you don't want the entire page to change, but instead just a portion of the page. That's what Turbo Frames are all about. Links and Form submissions that are trapped inside a Turbo Frame tag (or that point to one!) will instruct Turbo Drive to NOT replace the entire body of the document, but instead to look for a matching Turbo Frame in the response using its DOM ID and replace that specific portion of the page.
Here's how you can use Turbo Frames:
<turbo-frame id="my_frame">
<h1>Hello, World!</h1>
<a href="/somewhere">
I'm a trigger. My response must have a matching Turbo Frame tag (same ID)
</a>
</turbo-frame>
Turbo Frames also allows you to lazy-load the frame's content. You may do so by adding a src
attribute to the Turbo Frame tag. The conetnt of a lazy-loading Turbo Frame tag can be used to indicate "loading states", such as:
<turbo-frame id="my_frame" src="{{ route('my.page') }}">
<p>Loading...</p>
</turbo-frame>
Turbo will automatically fire a GET AJAX request as soon as a lazy-loading Turbo Frame enters the DOM and replace its content with a matching Turbo Frame in the response.
You may also trigger a Turbo Frame with forms and links that are outside of such frames by pointing to them like so:
<div>
<a href="/somewhere" data-turbo-frame="my_frame">I'm a link</a>
<turbo-frame id="my_frame"></turbo-frame>
</div>
You could also "hide" this link and trigger a "click" event with JavaScript programmatically to trigger the Turbo Frame to reload, for example.
So far, all vanilla Hotwire and Turbo.
Since Turbo rely a lot on DOM IDs, the package offers a helper to generate unique DOM IDs based on your models. You may use the @domid
Blade Directive in your Blade views like so:
<turbo-frame id="@domid($comment)">
<!-- Content -->
</turbo-frame>
This will generate a DOM ID string using your model's basename and its ID, such as comment_123
. You may also give it a content that will prefix your DOM ID, such as:
<turbo-frame id="@domid($post, 'comments_count')">(99)</turbo-frame>
Which will generate a comments_count_post_123
DOM ID.
The package also ships with a namespaced dom_id()
helper function so you can use it outside of your own views:
use function Tonysm\TurboLaravel\dom_id;
dom_id($comment);
When a new instance of a model is passed to any of these DOM ID helpers, since it doesn't have an ID, it will prefix the resource anme with a create_
prefix. This way, new instances of an App\\Models\\Comment
model will generate a create_comment
DOM ID.
These helpers strip out the model's FQCN (see config/turbo-laravel.php if you use an unconventional location for your models).
As mentioned earlier, out of everything Turbo provides, it's Turbo Streams that benefit the most from a back-end integration.
Turbo Drive will get your pages behaving like an SPA and Turbo Frames will allow you to have a finer grained control of chunks of your page instead of replace the entire page when a form is submitted or a link is clicked.
However, sometimes you want to update multiple parts of you page at the same time. For instance, after a form submission to create a comment, you may want to append the comment to the comment's list and also update the comment's count in the page. You may achieve that with Turbo Streams.
Any non-GET form submission will get annotated by Turbo with a Content-Type: text/vnd.turbo-stream.html
header (besides the other normal Content Types). This will indicate your back-end that you can return a Turbo Stream response for that form submission if you want to.
Here's an example of a route handler detecting and returning a Turbo Stream response to a form submission:
Route::post('posts/{post}/comments', function (Post $post) {
$comment = $post->comments()->create(/** params */);
if (request()->wantsTurboStream()) {
return response()->turboStream()->append($comment);
}
return back();
});
The request()->wantsTurboStream()
macro added to the request will check if the request accepts Turbo Stream and return true
or false
accordingly.
Here's what the HTML response will look like:
<turbo-stream action="append" target="comments">
<template>
@include('comments._comment', ['comment' => $comment])
</template>
</turbo-stream>
Most of these things were "guessed" based on the naming conventions we talked about earlier. But you can override most things, like so:
return response()->turboStream($comment)->target('post_comments');
The model is optional, as it's only used to figure out the defaults based on the model state. You could manually create that same response like so:
return response()->turboStream()
->target('comments')
->action('append')
->view('comments._comment', ['comment' => $comment]);
There are 5 actions in Turbo Streams. They are:
append
&prepend
: to add the elements in the target element after the existing contents or before, respectivelyreplace
: will replace the existing element entirely with the contents of thetemplate
tag in the Turbo Streamupdate
: will keep the target and only replace the contents of it with the contents of thetemplate
tag in the Turbo Streamremove
: will remove the element. This one doesn't need a<template>
tag. It accepts either an instance of a Model or the DOM ID of the element to be removed as a string.
Which means you will find shorthand methods for them all, like:
response()->turboStream()->append($comment);
response()->turboStream()->prepend($comment);
response()->turboStream()->replace($comment);
response()->turboStream()->update($comment);
response()->turboStream()->remove($comment);
You can read more about Turbo Streams in the Turbo Handbook.
These shorthand methods return a pending object for the response which you can chain and override everything you want on it:
return response()->turboStream()
->append($comment)
->view('comments._comment_card', ['comment' => $comment]);
As mentioned earlier, passing a model to the response()->turboStream()
macro will pre-fill the pending response object with some defaults based on the model's state.
It will build a remove
Turbo Stream if the model was deleted (or if it is trashed - in case it's a Soft Deleted model), an append
if the model was recently created (which you can override the action as the second parameter of the macro), a replace
if the model was just updated (you can also override the action as the second parameter.) Here's how overriding would look like:
return response()->turboStream($comment, 'append');
If you're not using the model partial convention or if you have some more complex Turbo Stream constructs, you may use the response()->turboStreamView()
version instead and specify your own Turbo Stream views.
This is what it looks like:
return response()->turboStreamView('comments.turbo.created_stream', [
'comment' => $comment,
]);
And here's an example of a more complex custom Turbo Stream view:
@include('layouts.turbo.flash_stream')
<turbo-stream target="comments" action="append">
<template>
@include('comments._comment', ['comment' => $comment])
</template>
</turbo-stream>
Remember, these are Blade views, so you have the full power of Blade at your hands. In this example, we're including a shared Turbo Stream partial which could append any flash messages we may have. That layouts.turbo.flash_stream
could look like this:
@if (session()->has('status'))
<turbo-stream target="notice" action="append">
<template>
@include('layouts._flash')
</template>
</turbo-stream>
@endif
I hope you can see how powerful this can be to reusing views.
So far, we have used Turbo Streams over HTTP to handle the case of updating multiple parts of the page for a single user after a form submission. In addition to that, you may want to broadcast model changes over WebSockets to all users that are viewing the same page. Although nice, you don't have to use WebSockets if you don't have the need for it. You may still benefit from Turbo Streams over HTTP.
Those same Turbo Stream responses we are returning to a user after a form submission, we can also send those to other users connected to a Laravel Echo channel and have their pages update reflecting the model change made by other users.
You may still feed the user making the changes with Turbo Streams over HTTP and broadcast the changes to other users over WebSockets. This way, the user making the change will have an instant feedback compared to having to wait for a background worker to pick up the job and send it to them over WebSockets.
First, setup the Laravel Broadcasting component for your app. One of the first steps is to configure your environment variables to something that looks like this:
PUSHER_APP_ID=
PUSHER_APP_KEY=
PUSHER_APP_SECRET=
PUSHER_APP_CLUSTER=us2
PUSHER_APP_HOST=websockets.test
PUSHER_APP_PORT=6001
MIX_PUSHER_APP_KEY="${PUSHER_APP_KEY}"
MIX_PUSHER_APP_CLUSTER="${PUSHER_APP_CLUSTER}"
MIX_PUSHER_APP_HOST="localhost"
MIX_PUSHER_APP_PORT="${PUSHER_APP_PORT}"
MIX_PUSHER_APP_USE_SSL=false
Notice that some of these environment variables are used by your front-end assets during compilation. That's why you see some duplicates that are just prefixed with MIX_
.
These settings assume you're using the Laravel WebSockets package. Check out the resources/js/echo.js for the suggested dotenv credentials you may need to configure. You may also use Pusher instead of the Laravel WebSockets package, if you don't want to host it yourself.
With Laravel Echo properly configured, you may now broadcast model changes using WebSockets. First thing you need to do is use the Broadcasts
trait in your model:
use Tonysm\TurboLaravel\Models\Broadcasts;
class Comment extends Model
{
use Broadcasts;
}
This trait will add some methods to your model that you can use to trigger broadcasts. Here's how you can broadcast appending a new comment to all users visiting the post page:
Route::post('posts/{post}/comments', function (Post $post) {
$comment = $post->comments()->create(/** params */);
$comment->broadcastAppend()->later();
if (request()->wantsTurboStream()) {
return response()->turboStream($comment);
}
return back();
});
Here are the methods now available to your model:
$comment->broadcastAppend();
$comment->broadcastPrepend();
$comment->broadcastReplace();
$comment->broadcastUpdate();
$comment->broadcastRemove();
These methods will assume you want to broadcast the Turbo Streams to your model's channel. However, you will also find alternative methods where you can specify either a model or the broadcasting channels you want to send the broadcasts to:
$comment->broadcastAppendTo($post);
$comment->broadcastPrependTo($post);
$comment->broadcastReplaceTo($post);
$comment->broadcastUpdateTo($post);
$comment->broadcastRemoveTo($post);
These broadcastXTo()
methods accept either a model, a channel instance or an array containing both of these. When it receives a model, it will guess the channel name using the broadcasting channel convention (see #conventions).
All of these broadcasting methods return an instance of a PendingBroadcast
class that will only dispatch the broadcasting job when that pending object is being garbage collected. Which means that you can control a lot of the properties of the broadcast by chaining on that instance before it goes out of scope, like so:
$comment->broadcastAppend()
->to($post)
->partial('comments/_custom_partial', [
'comment' => $comment,
'post' => $post,
])
->toOthers() // do not send to the current user
->later(); // dispatch a background job to send
You may want to hook those methods in the model events of your model to trigger Turbo Stream broadcasts whenever your models are changed in any context, such as:
class Comment extends Model
{
use Broadcasts;
protected static function booted()
{
static::created(function (Comment $comment) {
$comment->broadcastPrependTo($comment->post)
->toOthers()
->later();
});
static::updated(function (Comment $comment) {
$comment->broadcastReplaceTo($comment->post)
->toOthers()
->later();
});
static::deleted(function (Comment $comment) {
$comment->broadcastRemoveTo($comment->post)
->toOthers()
->later();
});
}
}
In case you want to broadcast all these changes automatically, instead of specifying them all, you may want to add a $broadcasts
property to your model, which will instruct the Broadcasts
trait to trigger the Turbo Stream broadcasts for the created, updated and deleted model events, like so:
class Comment extends Model
{
use Broadcasts;
protected $broadcasts = true;
}
This will achieve almost the same thing as the example where we registered the model events manually, with a couple nuanced differences. First, by default, it will broadcast an append
Turbo Stream to newly created models. You may want to use prepend
instead. You can do so by using an array with a insertsBy
key and prepend
action as value instead of a boolean, like so:
class Comment extends Model
{
use Broadcasts;
protected $broadcasts = [
'insertsBy' => 'prepend',
];
}
This will also automatically hook into the model events, but instead of broadcasting new instances as append
it will use prepend
.
Secondly, it will send all changes to this model's broadacsting channel. In our case, we want to direct the broadcasts to the post linked to this model instead. We can achieve that by adding a $broadcastsTo
property to the model, like so:
class Comment extends Model
{
use Broadcasts;
protected $broadcasts = [
'insertsBy' => 'prepend',
];
protected $broadcastsTo = 'post';
public function post()
{
return $this->belongsTo(Post::class);
}
}
That property can either be a string that contains to the name of a relationship of this model or an array of relationships.
Alternatively, you may prefer to have more control over where these broadcasts are being sent to by implementing a broadcastsTo
method in your model instead of using the property. This way, you can return a single model, a broadcasting channel instance or an array containing either of them, like so:
use Illuminate\Broadcasting\Channel;
class Comment extends Model
{
use Broadcasts;
protected $broadcasts = [
'insertsBy' => 'prepend',
];
public function post()
{
return $this->belongsTo(Post::class);
}
public function broadcastsTo()
{
return [
$this,
$this->post,
new Channel('full-control'),
];
}
}
You may listen to a Turbo Stream broadcast message on your pages by adding the custom HTML tag <turbo-echo-stream-source>
that is published to your application's assets (see here). You need to pass the channel you want to listen to broadcasts on using the channel
attribute of this element, like so.
<turbo-echo-stream-source
channel="App.Models.Comments.{{ $comment->id }}"
/>
By default, it expects a private channel, so the tag must be used in a page for already authenticated users. You can control the channel type in the tag with a type
attribute.
<turbo-echo-stream-source
channel="App.Models.Comments.{{ $comment->id }}"
type="presence"
/>
There is a helper blade directive that you can use to generate the channel name for your models using the same convention:
<turbo-echo-stream-source
channel="@channel($comment)"
/>
Alternatively, the package also offers some helper functions to generate the channel names following the package's conventions, like so:
use function Tonysm\TurboLaravel\turbo_channel;
// returns "App.Models.Comment.123"
turbo_channel($comment);
There is also a helper you can use to generate the channel name on your broadcasting authorization:
// file: routes/channels.php
use App\Models\Post;
use App\Models\User;
use Illuminate\Support\Facades\Broadcast;
use function Tonysm\TurboLaravel\turbo_channel_auth as channelFor;
Broadcast::channel(channelFor(Post::class), function (User $user, Post $post) {
return $user->belongsToTeam($post->team);
});
You may want to read the Laravel Broadcasting documentation.
As mentioned erlier, you may want to feed the current user with Turbo Streams using HTTP requests and only send the broadcasts to other users. There are a couple ways you can achieve that.
First, you can chain on the broadcasting methods, like so:
$comment->broadcastAppendTo($post)
->toOthers();
Second, you can use the Turbo Facade like so:
use Tonysm\TurboLaravel\Facades\Turbo;
Turbo::broadcastToOthers(function () {
// ...
});
This way, any broadcast that happens inside the scope of the closure will only be sent to other users.
Third, you may use that same method but without the closure inside a ServiceProvider, for instance, to instruct the package to only send turbo stream broadcasts to other users globally:
<?php
namespace App\Providers;
use Illuminate\Support\ServiceProvider;
use Tonysm\TurboLaravel\Facades\Turbo;
class AppServiceProvider extends ServiceProvider
{
public function boot()
{
Turbo::broadcastToOthers();
}
}
By default, Laravel will redirect failed validation exceptions "back" to the page the triggered the request. This is a bit problematic when it comes to Turbo Frames, since a form might be included in a page that don't render the form initially, and after a failed validation exception from a form submission we would want to re-render the form with the invalid messages.
In other words, a Turbo Frame inherits the context of the page where it was inserted in, and a form might not be part of that page itself. We can't redirect "back" to display the form again with the error messages, because the form might not be re-rendered there by default. Instead, we have two options:
- Render a Blade view with the form as a non-200 HTTP Status Code, then Turbo will look for a matching Turbo Frame inside the response and replace only that portion or page, but it won't update the URL as it would for other Turbo Visits; or
- Redirect the request to a page that renders the form directly instead of "back". There you can render the validation messages and all that. Turbo will follow the redirect (303 Status Code) and fetch the Turbo Frame with the form and invalid messages and update the existing one.
When using the \Tonysm\TurboLaravel\Http\Middleware\TurboMiddleware
middleware that ships with the package on your HTTP Kernel's "web" route group, it will override Laravel's default handling for failed validation exceptions.
For any route name ending in .store
, it will redirect to a .create
route for the same resource with all the route params from the previous request. In the same way, for any .update
routes, it will redirect to a .edit
route of the same resource.
Examples:
posts.comments.store
will redirect toposts.comments.create
with the{post}
route param.comments.store
will redirect tocomments.create
with no route params.comments.update
will redirect tocomments.edit
with the{comment}
param.
If a guessed route name doesn't exist, the middleware will not change the redirect response. You may override this behavior by catching the ValidationException
yourself and re-throwing it overriding the redirect with the redirectTo
method. If the exception has that, the middleware will respect it.
public function store()
{
try {
request()->validate(['name' => 'required']);
} catch (\Illuminate\Validation\ValidationException $exception) {
throw $exception->redirectTo(url('/somewhere'));
}
}
You may also catch the ValidationException
and return a non-200 response, if you want to.
Hotwire also has a mobile side, and the package provides some goodies on this front too.
Turbo Visits made by a Turbo Native client will send a custom User-Agent
header. So we added another Blade helper you may use to toggle fragments or assets (such as mobile specific stylesheets) on and off depending on whether your page is being rendered for a Native app or a Web app:
@turbonative
<h1>Hello, Mobile Users!</h1>
@endturbonative
You may also check if the request was made from a Turbo Native visit using the TurboFacade, like so:
if (\Tonysm\TurboLaravel\Facades\Turbo::isTurboNativeVisit()) {
// Do something for mobile specific requests.
}
There is a companion package that you may use as a dev dependency on your application to help with testing your apps using Turbo Laravel. First, install the package:
composer require tonysm/turbo-laravel-test-helpers --dev
And then you will be able to test your application like:
use Tonysm\TurboLaravelTestHelpers\Testing\InteractsWithTurbo;
class ExampleTest extends TestCase
{
use InteractsWithTurbo;
/** @test */
public function turbo_stream_test()
{
$response = $this->turbo()->post('my-route');
$response->assertTurboStream();
$response->assertHasTurboStream($target = 'users', $action = 'append');
$response->assertDoesntHaveTurboStream($target = 'empty_users', $action = 'remove');
}
/** @test */
public function turbo_native_shows()
{
$response = $this->turboNative()->get('my-route');
$response->assertSee('Only rendered in Turbo Native');
}
}
Check out the package repository if you want to know more about it.
All model's broadcast will dispatch a Tonysm\TurboLaravel\Jobs\BroadcastAction
job (either to a worker or process them immediately). You may also use that to test your broadcasts like so:
use App\Models\Post;
use Tonysm\TurboLaravel\Jobs\BroadcastAction;
use function Tonysm\TurboLaravel\turbo_channel;
class CreatesCommentsTest extends TestCase
{
/** @test */
public function creates_comments()
{
Bus::fake(BroadcastAction::class);
$post = Post::factory()->create();
$this->turbo()->post(route('posts.comments.store', $post), [
'content' => 'Hello, World',
])->assertTurboStream();
Bus::assertDispatched(function (BroadcastAction $job) use($post) {
return count($job->channels) === 1
&& $job->channels[0]->name === turbo_channel($post)
&& $job->target === 'comments'
&& $job->action === 'append'
&& $job->partial === 'comments._comment'
&& $job->partialData['comment']->is(
$post->comments->first()
);
});
}
}
Note: make sure your turbo-laravel.queue
config key is set to false, otherwise actions may not be dispatched during test because the model observer only fires them after the transaction is commited, which never happens in tests since they run inside a transaction.
Try the package out. Use your Browser's DevTools to inspect the responses. You will be able to spot every single Turbo Frame and Turbo Stream happening.
"The proof of the pudding is in the eating."
Make something awesome!
composer test
Please see CHANGELOG for more information on what has changed recently.
Please see CONTRIBUTING for details.
Drop me an email at [email protected] if you want to report security vulnerabilities.
The MIT License (MIT). Please see License File for more information.