Flag of Ukraine
SymfonyCasts stands united with the people of Ukraine

Manual Visits with Turbo

Keep on Learning!

If you liked what you've learned so far, dive in!
Subscribe to get access to this tutorial plus
video, code and script downloads.

Start your All-Access Pass
Buy just this tutorial for $12.00

With a Subscription, click any sentence in the script to jump to that part of the video!

Login Subscribe

Sometimes you need to trigger a Turbo visit programmatically... like after running some custom JavaScript, you want to send the user to another page.

Head over to your code and open assets/controllers/counter_controller.js. This very advanced Stimulus controller powers this high-tech "click for a chance to win" area. Each time I click the button, the counter goes up. Amazing!

Let's pretend that, after 10 clicks, the user wins and we want to redirect them to a "you won!" page. Let's first do this with normal JavaScript. Inside of the increment() method - which is called each time we click - say, if this.count equals 10, then redirect using raw JavaScript: window.location.href equals /you-won, which is a page I already created.

... lines 1 - 6
increment() {
this.countTarget.innerText = this.count;
if (this.count === 10) {
window.location.href = '/you-won';
... lines 15 - 16

Let's make sure this works. Refresh the homepage... click a bunch of times and... eureka! We're winners! But... that worked via a full page refresh, not via Turbo.

Could we navigate with Turbo? Totally! Start by importing Turbo into this file. This is the most complicated part because... the syntax looks a little funny. It's import * as Turbo from and then the name of the library, which is @hotwired/turbo. The * as Turbo is needed due to how that library exports things.

Down in the method, instead of window.location.href, we can say Turbo.visit() and pass in the URL.

import { Controller } from 'stimulus';
import * as Turbo from '@hotwired/turbo';
export default class extends Controller {
count = 0;
static targets = ['count'];
increment() {
this.countTarget.innerText = this.count;
if (this.count === 10) {

Let's try it again! Go back to the homepage and do a full page refresh. Actually... it did a full page refresh automatically because of the asset tracking we created in the last chapter. Cool!

Time to click! Watch when we get to 10. Beautiful! That navigated with Turbo. We can see the Ajax call right here. And... yea! It's just that easy.

But if you want to be more hipster, you can use de-structuring to just import the visit function. It looks like this import { visit } from '@hotwired/turbo'. Then below, literally call visit() as a function.

import { Controller } from 'stimulus';
import { visit } from '@hotwired/turbo';
export default class extends Controller {
count = 0;
static targets = ['count'];
increment() {
this.countTarget.innerText = this.count;
if (this.count === 10) {

This will work exactly the same as before.

What if Turbo isn't Available?

There's one other tricky situation that you might run into when it comes to navigating with Turbo: if you're writing JavaScript... but you are not in a file that's parsed by Webpack. In other words, you're somewhere where you can't use the import keyword.

This is probably not very common and, really, in a perfect world, 100% of our JavaScript will be written in a Webpack-parsed file.

But just in case, let's see how we can navigate with Turbo from inside some inline JavaScript on our page. Open up templates/base.html.twig and head to the bottom. Right before the closing </body>, add a <script> tag. We're going to pretend that when we click the logo... which has id="logo-img"... that we want to go to the cart page.

Do that by saying document.getElementById(), pass it, logo-img, .addEventListener('click') and pass an arrow function with an event argument. Inside, say event.preventDefault() so that it doesn't follow the link that the image is inside of. Oh... yikes! I forgot my comma. That's better.

How can we fetch the Turbo object to trigger the visit? It turns out... it's already available as a global variable! So we can immediately say: Turbo.visit('/cart')

... lines 1 - 96
document.getElementById('logo-img').addEventListener('click', (event) => {
... lines 103 - 105

That's it! But... who set Turbo as a global object? I don't remember doing that! Starting in Turbo 7 beta 6, when you import the @hotwired/turbo library, it automatically sets itself as a global variable. So if you have Turbo working on your site, there is a Turbo global variable, which is done to help with this exact situation.

Anyways, if we go and do a full page refresh... then click the logo image, instead of going to the homepage like it normally would, it navigates us - via Turbo - to the cart page.

Next, we are now done with all the Turbo Drive tricky parts! Before we move onto Turbo frames, let's try doing a few fun things. The first will be to experiment with adding CSS transitions as we navigate between pages with Drive.

Leave a comment!

Login or Register to join the conversation
Nick-F Avatar

I there some way to give turbo access to our symfony routes?
I'm currently using the jsrouting bundle in a non-turbo project to generate dynamic routes in js. The routes are created using data from an ajax response which is why I can't simply generate them in twig and pass them to the stimulus controller. It would be awesome if this functionality was built into Turbo.
Or is there a completely different way that I just don't know about


Hey Nick F. !

This is a very good question. I used the js routing bundle for a long time, but we don't use it anymore (to be more precise, we are - at this moment - removing the last few usages of it on our site). Before Stimulus (and also WITH Stimulus, using its values API), we now pass URLs as data- attributes in Twig.

Now, from your question, I can see that you're already aware of using data- attributes for this kind of thing. This IS what I would do, however, in 99% of the cases when I needed to do a manual visit in Turbo. But you mentioned:

> The routes are created using data from an ajax response

Ok, so you basically have a situation where you make an Ajax call and, based on the response of that Ajax call, you will determine what Url to visit with Turbo. If I had this situation, I would literally return the URL that you want to communicate back (instead of returning some "data" and then trying to generate the URL to a route using that data on the frontend).

Does that help? Technically speaking, you *could* continue to use the js routing bundle - there is no incompatibility between it and Turbo: you could generate a URL using the js routing bundle in a Stimulus controller, then pass the final URL to Turbo.visit().


Nick-F Avatar

I would do that if I was making a request to my own symfony controller, but I'm using a 3rd party javascript library.
I define the callback in a stimulus controller, and in the call back I create a custom event, attach the response data, and dispatch it. (I'm unable to use the stimulus controller values or methods from within the callback)
I then listen to that event on the stimulus controller element with a "data-action" that triggers another method in the controller which takes the event.detail data, generates a route with jsrouting, and redirects the page.


Hey Nick F.!

Hmmm. It does some a bit complex, bit I would probably try to (A) return the already-generated URL from the Ajax call so that I could (B) attach that URL to the event and finally (C) use that in the method of the other controller (e.g. event.detail.url). For (A), I'm assuming you're in total control over what the Ajax call returns, so you could add the URL to it somewhere (you could put it as an extra key in the JSON or even as a custom Location header), but I could be wrong about this assumption :).


Cat in space

"Houston: no signs of life"
Start the conversation!

What PHP libraries does this tutorial use?

// composer.json
    "require": {
        "php": ">=8.1",
        "ext-ctype": "*",
        "ext-iconv": "*",
        "composer/package-versions-deprecated": "", //
        "doctrine/annotations": "^1.0", // 1.13.1
        "doctrine/doctrine-bundle": "^2.2", // 2.3.2
        "doctrine/orm": "^2.8", // 2.9.1
        "phpdocumentor/reflection-docblock": "^5.2", // 5.2.2
        "sensio/framework-extra-bundle": "^6.1", // v6.1.4
        "symfony/asset": "5.3.*", // v5.3.0-RC1
        "symfony/console": "5.3.*", // v5.3.0-RC1
        "symfony/dotenv": "5.3.*", // v5.3.0-RC1
        "symfony/flex": "^1.3.1", // v1.18.5
        "symfony/form": "5.3.*", // v5.3.0-RC1
        "symfony/framework-bundle": "5.3.*", // v5.3.0-RC1
        "symfony/property-access": "5.3.*", // v5.3.0-RC1
        "symfony/property-info": "5.3.*", // v5.3.0-RC1
        "symfony/proxy-manager-bridge": "5.3.*", // v5.3.0-RC1
        "symfony/runtime": "5.3.*", // v5.3.0-RC1
        "symfony/security-bundle": "5.3.*", // v5.3.0-RC1
        "symfony/serializer": "5.3.*", // v5.3.0-RC1
        "symfony/twig-bundle": "5.3.*", // v5.3.0-RC1
        "symfony/ux-chartjs": "^1.1", // v1.3.0
        "symfony/ux-turbo": "^1.3", // v1.3.0
        "symfony/ux-turbo-mercure": "^1.3", // v1.3.0
        "symfony/validator": "5.3.*", // v5.3.0-RC1
        "symfony/webpack-encore-bundle": "^1.9", // v1.11.2
        "symfony/yaml": "5.3.*", // v5.3.0-RC1
        "twig/extra-bundle": "^2.12|^3.0", // v3.3.1
        "twig/intl-extra": "^3.2", // v3.3.0
        "twig/string-extra": "^3.3", // v3.3.1
        "twig/twig": "^2.12|^3.0" // v3.3.2
    "require-dev": {
        "doctrine/doctrine-fixtures-bundle": "^3.4", // 3.4.0
        "symfony/debug-bundle": "^5.2", // v5.3.0-RC1
        "symfony/maker-bundle": "^1.27", // v1.31.1
        "symfony/monolog-bundle": "^3.0", // v3.7.0
        "symfony/stopwatch": "^5.2", // v5.3.0-RC1
        "symfony/var-dumper": "^5.2", // v5.3.0-RC1
        "symfony/web-profiler-bundle": "^5.2", // v5.3.0-RC1
        "zenstruck/foundry": "^1.10" // v1.10.0

What JavaScript libraries does this tutorial use?

// package.json
    "devDependencies": {
        "@babel/preset-react": "^7.0.0", // 7.13.13
        "@fortawesome/fontawesome-free": "^5.15.3", // 5.15.3
        "@hotwired/turbo": "^7.0.0-beta.5", // 1.2.6
        "@popperjs/core": "^2.9.1", // 2.9.2
        "@symfony/stimulus-bridge": "^2.0.0", // 2.1.0
        "@symfony/ux-chartjs": "file:vendor/symfony/ux-chartjs/Resources/assets", // 1.1.0
        "@symfony/ux-turbo": "file:vendor/symfony/ux-turbo/Resources/assets", // 0.1.0
        "@symfony/ux-turbo-mercure": "file:vendor/symfony/ux-turbo-mercure/Resources/assets", // 0.1.0
        "@symfony/webpack-encore": "^1.0.0", // 1.3.0
        "bootstrap": "^5.0.0-beta2", // 5.0.1
        "chart.js": "^2.9.4",
        "core-js": "^3.0.0", // 3.13.0
        "jquery": "^3.6.0", // 3.6.0
        "react": "^17.0.1", // 17.0.2
        "react-dom": "^17.0.1", // 17.0.2
        "regenerator-runtime": "^0.13.2", // 0.13.7
        "stimulus": "^2.0.0", // 2.0.0
        "stimulus-autocomplete": "https://github.com/weaverryan/stimulus-autocomplete#toggle-event-always-dist", // 2.0.0
        "stimulus-use": "^0.24.0-1", // 0.24.0-2
        "sweetalert2": "^11.0.8", // 11.0.12
        "webpack-bundle-analyzer": "^4.4.0", // 4.4.2
        "webpack-notifier": "^1.6.0" // 1.13.0