Flag of Ukraine
SymfonyCasts stands united with the people of Ukraine

Redirecting the Full Page from a Frame

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

We just did something pretty custom. Normally, if you submit a form into a frame, if that frame redirects, the new content will be loaded into the frame only. The URL in the address bar won't change and the rest of the page won't be affected. That's usually what you want!

But sometimes, we do want to navigate the entire page, like in a modal. Or, imagine that you have a sidebar with a form. When you submit and fail validation, you do want that to show in the sidebar. But once the form is successful, you want to navigate the entire window to a confirmation page.

So let's make our frame-redirecting system something that we can use anywhere. Here's the plan: if a turbo-frame - like the turbo-frame in _modal.html.twig - has a data-turbo-form-redirect="true" attribute - which I totally just invented - then we will redirect the whole page if we detect a redirect in that frame.

class="modal fade"
<div class="modal-dialog">
<div class="modal-content">
... lines 9 - 14
src="{{ modalSrc }}"
id="{{ id }}"
{{ modalContent|default('Loading...') }}

Moving Code to turbo-helper

Because this new redirect behavior will be something that will work anywhere on our site, we need to move the logic out of our modal-form controller and into turbo-helper where the rest of our global Turbo stuff lives.

Copy the beforeFetchResponse() method and delete it. Then, in turbo-helper, paste this at the bottom. Cool.

... lines 1 - 3
const TurboHelper = class {
... lines 5 - 103
beforeFetchResponse(event) {
if (!this.modal || !this.modal._isShown) {
const fetchResponse = event.detail.fetchResponse;
if (fetchResponse.succeeded && fetchResponse.redirected) {
... lines 116 - 118

Back in modal-form_controller, we don't need the disconnect() method anymore. We're going to register this listener just once inside of turbo-helper. Copy part of connect(), delete the rest... and we can also remove the Turbo import.

... lines 1 - 3
export default class extends Controller {
static targets = ['modal'];
modal = null;
async openModal(event) {
this.modal = new Modal(this.modalTarget);

Over in turbo-helper, go up to the constructor - here it is - and paste. To call the method, pass an arrow function with an event argument and call this.beforeFetchResponse(event).

... lines 1 - 3
const TurboHelper = class {
constructor() {
... lines 6 - 16
document.addEventListener('turbo:before-fetch-response', (event) => {
... line 22
... lines 24 - 114
... lines 116 - 118

Finding the "Active" Frame, if any, for a Request

Ok - go back down to that method. This is not going to work yet... because it's still coded to work with a modal. To bring this to life, we need determine three things. One: was the Ajax call redirected? Two: did this navigation happen inside of a Turbo frame? And three: does that frame have the data-turbo-form-redirect attribute?


Starting in Turbo 7 RC4 (and so also in the stable Turbo 7), the turbo:before-fetch-response event is now passed which element the Ajax call was triggered on, as event.target. You could use this to find the "current turbo-frame" via event.target.closest('turbo-frame').

The trickiest of these three is actually figuring out if this Ajax call is happening inside of a turbo frame. This event doesn't give us any indication of what initiated the Ajax call - like which link was clicked or which form was submitted. But, we can use a trick. Remember: whenever a frame is loading, turbo gives that frame a busy attribute. We can use that.

Create a new convenience method called getCurrentFrame(). This is going to return the turbo-frame Element that is currently loading or null. And it's as simple as return document.querySelector() looking for turbo-frame[busy].

... lines 1 - 3
const TurboHelper = class {
... lines 5 - 115
getCurrentFrame() {
return document.querySelector('turbo-frame[busy]');
... lines 120 - 122

It is theoretically possible that two frames could be loading at the same time. But other than on initial page load if you had multiple lazy frames, I think that's pretty unlikely.

Above, let's use this. Remove all of this modal stuff... and then move the event.preventDefault() and Turbo.visit() to the end of the method... because we're going to reverse the if logic to keep things clean. If the fetchResponse did not succeed or it's not a redirect, then return and do nothing.

But if the response was successful and was a redirect, we need to see if we are inside of a frame and make sure that the frame has our data attribute. If not this.getCurrentFrame(), then return and do nothing. And if the current frame does not have .dataset.turboFormRedirect, also do nothing.

... lines 1 - 3
const TurboHelper = class {
... lines 5 - 103
beforeFetchResponse(event) {
const fetchResponse = event.detail.fetchResponse;
if (!fetchResponse.succeeded || !fetchResponse.redirected) {
if (!this.getCurrentFrame() || !this.getCurrentFrame().dataset.turboFormRedirect) {
... lines 117 - 120
... lines 122 - 124

At this point, we know that the Ajax call did happen inside of a frame with our data attribute and that the Ajax call did redirect to another page. And so, we prevent the frame from rendering and navigate the entire page.

Let's try it! Refresh, open the modal, fill in some info, submit and... got it! I know that worked because the new product showed up thanks to the Turbo visit.

Yay! But... was that too easy? It... kind of was. There are two annoying bugs that are hiding inside of our new system. Let's add one more turbo frame next that will expose both of them. Don't worry, by the end, we're going to have a beautiful bug-free way to force a frame to navigate the whole page.

Leave a comment!

Login or Register to join the conversation
Ole Avatar

I'm sorry, I did not completly understand, how is the turbo-helper instantiated? Does/should it happen automatically, oder do i have to create it from within app.js manually?


Hey @Ole!

No worries :). It IS imported from app.js - we did it WAY back on chapter 11 - https://symfonycasts.com/screencast/turbo/organize#codeblock-b154ca5114

Let me know if that helps!


Ole Avatar

Thanks for the quick reply :-)

Well, OK, importing the js file seems to be enough, at least beforeFetchResponse is called now. But I have one more problem now:

Altough beforeFetchResponse is called, it is only called for the second request, which is already redirected. It is never called for the first redirect response (Status Code 302/303).

Is this the expected behaviour? Seems a little bit irritating to me.

Also, the second requests generate an exception:


Unable to find template "default/default.turbo_stream.twig"`

(while calling that URL directly in the browser renders just fine using an completly different template).

Sorry, if this sounds a bit confusing.

Ole Avatar
Ole Avatar Ole | Ole | posted 6 months ago | edited

Well, this works for me now, I'm not sure what caused this, maybe it was an cache issue? I also added an empty "templates/default/default.turbo_stream.twig" file to the project tree. Anyway, thanks for your support :-)!


Yea, sometimes the cache is the problem. When I'm not sure if the my browser downloaded the most recent assets I just do a hard refresh


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