Integrate Laravel With Turbolinks and Stimulus
I had a chance earlier this month to convert a fairly generic CRUD-y application that I built in Laravel into something that a little bit more interactive. Since I already got all the interaction nailed down in Laravel including form validation and routing, I decided to use Turbolinks and Stimulus to make the application snappier and behave more like a single page application.
Since both Turbolinks and Stimulus come from the same developer, they pair quite nicely. However, to make them play nice with Laravel, few things need to be taken care of before.
Note: The rest of this article will be based on the default Laravel install, so you probably need to tweak it accordingly based on your application.
Prerequisites
First, install the required dependencies:
npm install turbolinks stimulus
Next, we’ll need to tweak the entry point of our Javascript. For the default install, this will be in resources/js/app.js
. Add these lines:
import Turbolinks from 'turbolinks';
Turbolinks.start();
import { Application } from 'stimulus';
import { definitionsFromContext } from 'stimulus/webpack-helpers';
const application = Application.start();
const context = require.context('./controllers', true, /\.js$/);
application.load(definitionsFromContext(context));
Please note that all of our Stimulus controllers need to be created inside the resources/js/controllers
directory.
One last step we need to do for the initial setup is to add support for the class property during our Babel compilation. Add this line into the .babelrc
file. You might need to create one if it doesn’t exist.
{
"plugins": ["@babel/plugin-proposal-class-properties"]
}
By now, Turbolinks is enabled and will start intercepting all requests by default.
Adding a global middleware to our Laravel application
As mentioned, all normal requests should now get intercepted by Turbolinks. However, it might not be able to handle our redirection properly if we define something like this in our routes/web.php
file.
Route::redirect('/route-1', '/route-2');
To solve this issue, we can create a global web
middleware that’ll get executed on every request. I created this app/Http/Middleware/SetTurbolinksHeader.php
and set it to load in the web
middleware group.
<?php
// app/Http/Middleware/SetTurbolinksHeader.php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Support\Facades\Session;
class SetTurbolinksHeader
{
/**
* Handle an incoming request.
*
* @param \Illuminate\Http\Request $request
* @param \Closure $next
* @return mixed
*/
public function handle(Request $request, Closure $next)
{
$response = $next($request);
if ($request->ajax() && !$request->isMethod('GET') && $response instanceof RedirectResponse) {
$response->setContent($response->getTargetUrl());
$response->setStatusCode(202);
} else {
$response->header('TurboLinks-Location', $request->url());
}
return $response;
}
}
And in our app/Http/Kernel.php
file:
<?php
...
protected $middlewareGroups = [
'web' => [
\App\Http\Middleware\EncryptCookies::class,
\Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse::class,
\Illuminate\Session\Middleware\StartSession::class,
// \Illuminate\Session\Middleware\AuthenticateSession::class,
\Illuminate\View\Middleware\ShareErrorsFromSession::class,
\App\Http\Middleware\VerifyCsrfToken::class,
\Illuminate\Routing\Middleware\SubstituteBindings::class,
\App\Http\Middleware\SetTurbolinksHeader::class,
],
'api' => [
'throttle:api',
\Illuminate\Routing\Middleware\SubstituteBindings::class,
],
];
...
I’ll explain about the line
if ($request->ajax() && !$request->isMethod('GET') && $response instanceof RedirectResponse) {
in the next section.
Adding a dedicated FormRequest based class
In my application, I make heavy use of Form Request validation for all of the form submissions. I created a TurbolinksFormRequest
that extends the default Laravel’s FormRequest
class that overrides the failedValidation
method. The whole class is pretty simple, it creates a new response, with all the user inputs and any validation errors and throws a new ValidationException
exception.
The content of the class is as follows:
<?php
// app/Http/Requests/TurbolinksFormRequest.php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Contracts\Validation\Validator;
use Illuminate\Validation\ValidationException;
class TurbolinksFormRequest extends FormRequest
{
protected $dontFlash = [
'password',
'password_confirmation',
];
protected function failedValidation(Validator $validator)
{
$response = $this->redirector->to($this->getRedirectUrl())
->withInput($this->except($this->dontFlash))
->withErrors($validator->errors(), $this->errorBag);
throw new ValidationException($validator, $response);
}
}
There is no reliable way for me to get the array of $dontFlash
from the app/Exception/Handler.php
file so I decided to just copy the array again into this class. Now, all we need to do is to change any FormRequest
instance that we have in our controllers with TurbolinksFormRequest
and we’re good to go. The middleware that we have before will automatically set the content to the redirected URL and set the status code to 202
.
For example, if we have something like this in our route file:
<?php
Route::post('/contact', function (FormRequest $request) {
// send the email
return redirect()->back();
});
We can convert it to:
<?php
Route::post('/contact', function (TurbolinksFormRequest $request) {
// send the email
return redirect()->back();
});
Stimulus-powered AJAX Form
The last step is to convert all of our forms to the Stimulus version of it. To begin, create a new controller inside the resources/js/controllers
folder. I’ll name it ajaxform_controller.js
to follow the naming convention mentioned in its handbook.
At its very basic form, the controller will look like this:
import { Controller } from "stimulus"
export default class extends Controller {
submitForm(e) {
e.preventDefault();
axios.post(this.element.action, new FormData(this.element))
.then(function (res) {
Turbolinks.clearCache();
Turbolinks.visit(res.data);
})
.catch(function (error) {
console.error(error);
})
.finally(function () {
});
}
}
Remember the line in custom middleware that we created previously?
if ($request->ajax() && !$request->isMethod('GET') && $response instanceof RedirectResponse) {
Now all AJAX requests will return a redirect URL in the response body. So all this class needs to do is to prevent default form submission and do a request using Axios library instead. We then can use the retuned URL to redirect form submission using the Turbolinks.visit(res.data)
. To use this controller in our existing forms, add the related attribute data-controller
and data-action
into our form.
For example, if we have a form like this:
<form action="/contact" method="post">
@csrf
... form elements
</form>
Change it to be
<form action="/contact" method="post" data-controller="form" data-action="form#submitForm">
@csrf
... form elements
</form>
Conclusion
Integrating Turbolinks and Stimulus into a Laravel application is fairly easy to do. With minimal changes in the existing codebase and the fact that they pair nicely together, it can be a powerful combination to use to make our app feels snappier and behaving more like a single page application.