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!

What PHP libraries does this tutorial use?

// composer.json
    "require": {
        "php": ">=7.4.0",
        "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