helthe/turbolinks-bundle
Helthe Turbolinks Bundle integrates Turbolinks with Symfony, replacing full page reloads with faster AJAX navigation. It adds the required JavaScript, response headers, and Twig helpers so your Symfony app feels quicker with minimal changes.
First, the honest framing: helthe/turbolinks-bundle is a Symfony bundle (Helthe\Bundle\TurbolinksBundle), not a Laravel package. You cannot composer require it into a Laravel app, and with 0 dependents, a Symfony 2-era structure, and no commits in years, you shouldn't try. What has value here is the behavior the bundle packages up — Turbolinks request detection and redirect rewriting — which you can reproduce in Laravel in about 60 lines.
Read these two files first in the repo, because they are the entire idea:
EventListener/TurbolinksListener.php — inspects the response on kernel.response. If the request is a Turbolinks visit and the response is a redirect, it strips the 302 body and replaces it with the Turbolinks-Location header so the client can history.pushState correctly.Turbolinks/Turbolinks.php (the helthe.turbolinks service) — the request detector (isTurbolinksRequest()) and helpers for setting/reading the redirect location.Then look at the Twig extension — it exists only to expose turbolinks_location() to templates, which in Laravel becomes a Blade directive or just a response header.
Minimal Laravel setup (your actual first use case):
npm install turbolinks
resources/js/app.js:
import Turbolinks from 'turbolinks'
Turbolinks.start()
resources/views/layouts/app.blade.php:
<head>
<meta name="csrf-token" content="{{ csrf_token() }}" data-turbolinks-track="reload">
<script src="{{ mix('js/app.js') }}" data-turbolinks-track="reload" defer></script>
</head>
First real win: every internal link in your app now swaps <body> without a full reload, and your existing Laravel redirects (return redirect()->route('posts.index')->with('status', 'Saved')) keep working because Turbolinks 5 follows redirects via fetch and reads the final response.url. You do not need the Symfony bundle's header-rewriting hack for the common case — that was largely a Turbolinks Classic (2.x) workaround.
The bundle's isTurbolinksRequest() equivalent. Useful for analytics exclusion, conditional fragment rendering, and skipping layout on partial visits.
// app/Http/Middleware/DetectTurbolinks.php
public function handle($request, Closure $next)
{
$isTurbolinks = str_contains($request->userAgent() ?? '', 'Turbolinks');
$request->attributes->set('turbolinks', $isTurbolinks);
view()->share('turbolinks', $isTurbolinks);
return $next($request);
}
Turbolinks 5 appends Turbolinks to the User-Agent and sends a Turbolinks-Referrer header on history restoration (back/forward). Use that header when you need to know the visit came from a restoration rather than a click.
Reimplement the listener as a terminable middleware. This matters when you redirect to a URL that differs from what the client can infer — redirect()->back(), redirect()->intended(), or a redirect after a DELETE where the referer is stale.
// app/Http/Middleware/TurbolinksRedirects.php
public function handle($request, Closure $next)
{
$response = $next($request);
if ($response->isRedirection()
&& str_contains($request->userAgent() ?? '', 'Turbolinks')) {
$response->headers->set('Turbolinks-Location', $response->headers->get('Location'));
}
return $response;
}
Register it globally in app/Http/Kernel.php after StartSession so flash data survives.
This is where 90% of your integration bugs live. DOMContentLoaded fires once; turbolinks:load fires on every visit.
document.addEventListener('turbolinks:load', () => {
// re-init dropdowns, date pickers, Select2, charts
initTooltips()
})
document.addEventListener('turbolinks:before-cache', () => {
// tear down anything you don't want frozen into the snapshot
document.querySelectorAll('.tooltip').forEach(el => el.remove())
if (window.Chart) window.Chart.instances.forEach(c => c.destroy())
})
document.addEventListener('turbolinks:before-visit', (e) => {
if (formIsDirty()) e.preventDefault()
})
Alpine.js and Livewire both need care: Alpine's x-data re-initializes on turbolinks:load fine, but Livewire components must be re-registered or the page will look interactive and do nothing. If your app is Livewire-heavy, consider Turbo Drive instead or scope Turbolinks to specific sections with data-turbolinks="false" on the Livewire routes.
Turbolinks intercepts non-GET form submissions the same way it intercepts links. Laravel's standard validate() → 302 back with $errors works unchanged, because Turbolinks follows the redirect and the 200 response contains your re-rendered Blade with the error bags.
The one real hazard is inertia-less partial rendering: don't return a bare view (no layout) from a controller that a Turbolinks form hits — the client replaces the whole <body> with your fragment. Always return full pages, or gate on turbolinks:
return $request->attributes->get('turbolinks')
? view('posts._list', compact('posts'))
: view('posts.index', compact('posts'));
Only do this if you're explicitly driving Turbolinks.visit from JS — otherwise keep it simple and always render the full page.
The bundle had no equivalent because Symfony's asset pipeline was already cache-friendly. In Laravel, put data-turbolinks-track="reload" on your mix()/vite() script and CSS tags. Turbolinks compares the tracking value on every visit and does a full reload if it changed — this is your deploy cache-busting mechanism and it's essential if you use long-lived CDN caching.
CSRF tokens go stale in the cache. Turbolinks snapshots the DOM. A user who leaves a tab open past the session lifetime, then navigates back, will submit an old _token and get a 419. Fix: put data-turbolinks-track="reload" on the <meta name="csrf-token"> tag so a changed token forces a reload, or refresh from /csrf-token on turbolinks:load.
Never cache authenticated-sensitive pages by accident. Turbolinks keeps the last snapshot in memory and on back/forward shows it without hitting the server. If you log out, the browser back button will still display the previous user's page. Turbolinks has no built-in logout handling — you must call Turbolinks.clearCache() before Turbolinks.visit('/logout'), or send Cache-Control: no-store on pages where it matters.
302 after POST is technically wrong; 303 is correct. Laravel's redirect() uses 302, which browsers historically convert to GET anyway. Turbolinks handles it, but if you're debugging "the form submitted twice" or "the redirect came back as POST," use return redirect()->route(...)->setStatusCode(303).
Downloads and external links need escape hatches. Any <a> pointing off-domain or to a file download should be data-turbolinks="false" (or you should call Turbolinks.visit(url, { action: 'advance' }) deliberately). Turbolinks will happily fetch a PDF into your <body> if you let it.
Vary: Turbolinks matters if you use full-page HTTP caching. If a response differs based on the request being a Turbolinks visit, your CDN/Varnish will serve the wrong variant. Set the Vary header from the middleware, or don't differentiate responses at all — which is the sane default.
Debugging: enable Turbolinks.setProgressBarDelay(0) in dev to see every request, and watch turbolinks:request-start / turbolinks:load in the console. The Chrome extension "Turbolinks 5" shows the current visit object and cache state. On the server side, tail the log for the Turbolinks UA string to confirm what's actually being sent — the middleware check is easy to get wrong with a strpos vs str_contains mismatch on an empty UA.
Extension points, if you want to port more of the bundle: the listener is a good place to add Turbolinks-Location on redirect()->back() only (the common failure case), rather than on every redirect — rewriting all redirect headers is more invasive than needed and can confuse non-Turbolinks clients behind a shared proxy.
The migration path is Turbo, not this package. Turbolinks 5 was renamed and superseded by Hotwire Turbo (Turbo Drive), which is actively maintained and has an official @hotwired/turbo package. The event names change (turbolinks:load → turbo:load) but every pattern above maps 1:1. If you're starting fresh, start with Turbo and skip Turbolinks entirely — the only thing you'd lose from helthe/turbolinks-bundle is nothing, because it was written for a version of Turbolinks that no longer exists.
How can I help you explore Laravel packages today?