Flag of Ukraine
SymfonyCasts stands united with the people of Ukraine

Turbo Frames: Lazy Frames

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

Time to move on to part two of Turbo: Turbo frames. This is a brand new feature - it did not exist in the old Turbolinks library. To put it simply, Turbo frames allow you to treat part of your page, well, basically like an iframe! If you've never worked with iframes or IE6, I'm jealous. Turbo frames are a native, non-weird way to get the goodness of iframes... without actually using iframes, which are a pain in the butt.

Imagine that this category sidebar were inside a Turbo frame. If it were, you could click these links or even submit forms and only the frame's content would change: the rest of the page would be unaffected.

Frames are super cool, but I do want us to keep something in mind: they're an "extra" feature. Turbo Drive gives us the single page app experience. Frames give us the ability to make the user experience even better. But using frames does mean that you'll need to write some extra code. Frames are form of progressive enhancement... which basically means that you should get your site working first, then come back to see where a tool like Turbo frames can enhance it further.

The 2 Use Cases for Frames

Ok, so there are basically 2 use-cases for Turbo frames. The first is what we just talked about: you want navigation in just one area of your page to happen inside that area without affecting the rest of your page.

The second use-case is when you want a part of your page to load lazily. Literally, an area of your site would be empty on page load... then that Turbo frame would make an Ajax call to populate itself.

Upgrading to the Latest Turbo

Before we jump into an example, I'm going to find my terminal and run:

yarn upgrade @hotwired/turbo

As a reminder @hotwired/turbo, is a normal library and you can find it in the package.json file.

34 lines package.json
... line 1
"devDependencies": {
"@babel/preset-react": "^7.0.0",
"@fortawesome/fontawesome-free": "^5.15.3",
"@hotwired/turbo": "^7.0.0-beta.5",
"@popperjs/core": "^2.9.1",
"@symfony/stimulus-bridge": "^2.0.0",
... lines 8 - 34

This line was added automatically when we installed the symfony/ux-turbo PHP package, but we have complete control over managing its version. When I originally downloaded it, I got version beta.5. The latest version at the time of recording, which you can see over here, is beta.7. Not a lot has changed between the two versions, but there was one tweak to how JavaScript works in frames that I want to get.

Setting up a Lazy Frame

Okay, at your browser, head to the cart page. We're going to talk about the second use-case for Turbo frames first: lazy frames. See this featured product sidebar? Let's pretend that rendering this is kind of a heavy. If we could load it lazily - so via an Ajax call - then the rest of the cart page could load faster because it wouldn't need to do the work of preparing and rendering that section.

To lazily load this, we first need a route and controller that renders the sidebar. Open the template for this page: templates/cart/cart.html.twig. Let's see... this is where we render the featured sidebar. And you can see that it's already isolated into its own template. So all we need to do is create a route & controller that render this template.

... lines 1 - 7
{% block body %}
<div class="container-fluid container-xl mt-4">
<div class="row">
<aside class="col-12 col-md-4 order-2 order-md-1">
{% if featuredProduct %}
{{ include('cart/_featuredSidebar.html.twig') }}
{% endif %}
... lines 16 - 32

Let's do that in src/Controller/CartController.php. This top method is the cart page itself. Copy that, paste below, rename it to _cartFeaturedProduct() and change the URL to /cart/_featured. I like to use that _ prefix when something only renders part of a page. Below, instead of rendering cart.html.twig, render _featuredSidebar.html.twig. And... we don't need to pass the cart variable... and so we don't need this CartStorage. Oh, and the route needs a unique name, like _app_cart_product_featured.

... lines 1 - 34
* @Route("/cart/_featured", name="_app_cart_product_featured")
public function _cartFeaturedProduct(ProductRepository $productRepository): Response
$featuredProduct = $productRepository->findFeatured();
$addToCartForm = $this->createForm(AddItemToCartFormType::class, null, [
'product' => $featuredProduct,
return $this->renderForm('cart/_featuredSidebar.html.twig', [
'featuredProduct' => $featuredProduct,
'addToCartForm' => $addToCartForm,
... lines 52 - 121

Cool. Now, up in the cart action, this will load faster because we can do less work... because we don't need to prepare the addToCartForm or fetch the featuredProduct anymore. We can even remove this argument.

The Custom <turbo-frame> Element

We can do all of this because, in the template for this action - cart.html.twig - we're not going to include this sidebar anymore. Instead, we're going to add a Turbo Frame... which is... just a custom HTML element - <turbo-frame> - which always has at least an id attribute that identifies it, like id="cart-sidebar".

... lines 1 - 10
<aside class="col-12 col-md-4 order-2 order-md-1">
<turbo-frame id="cart-sidebar" src="{{ path('_app_cart_product_featured') }}">
... lines 16 - 32

PhpStorm highlights this as an unknown tag, but the Turbo library does register it as a custom element.

If we stopped here, this would render an empty <turbo-frame> element on the page... and would do nothing. To make this a "lazy" frame, add a src attribute set to the URL that it should request to get its contents. In this case, that's {{ path() }} then _app_cart_product_featured. Inside the turbo-frame, we can put some loading text: this will show on page load while the Ajax call is being made.

That's it! With any luck, Turbo will see the frame, initiate the Ajax call and pop the response inside. Let's try it! Refresh and watch closely. Woh: the "Loading..." was there for just a second, then it disappeared! Check the console. Error!

Response has no matching <turbo-frame id="cart-sidebar"> element.

Interesting: it made the Ajax call and then looked for a turbo-frame element in the response with the same id as our frame. Why? The answer goes to the core of how Turbo frames work. Let's dive into that next and get this thing working.

Leave a comment!

Login or Register to join the conversation
jmsche Avatar
jmsche Avatar jmsche | posted 9 months ago | edited

For people using a more recent version of Turbo (in my case 7.2.4) there's no error in the console, but the page redirects to the /cart/_featured page instead.

Kamil Avatar

Did you solve this problem? I have the same case.

jmsche Avatar

IIRC it's a change in Turbo, so now no error is supposed to happen anymore.


Hey the team,

How you would set up the use of turbo-frame on an article detail page with comments ?

We could have a turbo-frame for displaying and adding comments in a method. But how to retrieve the information related to the article in this method.

Thank for your advice.


Hey Stephane!

Hmmm. I don't quite understand the question / situation. What do you mean by "adding comments in a method" - the method part doesn't quite make sense to me.

But, generally-speaking, we do something pretty similar on this tutorial. You could put a turbo-frame around the entire "comments" area... which has the advantage that only that area updates when a new comment is added. But I'm guessing there is more to your question - so I will let you elaborate :).



Hello Ryan,

Sorry for my unclear explanations.

It's ok I solved my problem with the rest of the tutorial and the use of turbo frame.


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