Flag of Ukraine
SymfonyCasts stands united with the people of Ukraine

Lucky you! You found an early release chapter - it will be fully polished and published shortly!

Forever Scroll with Turbo Frames

This Chapter isn't
quite ready...

Rest assured, the gnomes are hard at work
completing this video!

Browse Tutorials

You've made it to the final chapter of the Doctrine tutorial! This chapter is... a total bonus. Instead of talking about Doctrine, we're going to leverage some JavaScript to turn this page into a "forever scroll". But don't worry! We'll talk more about Doctrine in the next tutorial when we'll cover Doctrine Relations.

Here's the goal: instead of pagination links, I want this page to load nine results like we see on Page 1. Then, when we scroll to the bottom, I want to make an AJAX request to show the next nine results, and so on. The result is a "forever scroll".

In the first tutorial in the series, we installed a library called Symfony UX Turbo, which enabled a JavaScript library called Turbo. Turbo turns all of our link clicks and form submits into AJAX calls, giving us a really nice single page app-like experience without doing anything special.

Whelp, as cool as that is, Turbo has two other, optional superpowers: Turbo Frames and Turbo Streams. You can learn all about these in our Turbo tutorial. But let's get a quick sample of how we could leverage Turbo Frames to add forever scroll without writing a single line of JavaScript.

turbo-frame Basics!

Frames work by dividing parts of our page into separate turbo-frame elements, which acts a lot like an iframe... if you're old enough to remember those. When you surround something in a <turbo-frame>, any clicks inside of that frame will only navigate that one frame.

For example, open the template for this page - templates/vinyl/browse.html.twig - and scroll up to where we have our for loop. Add a new turbo-frame element right here. The only rule of a Turbo Frame is that it needs to have a unique ID. So say id="mix-browse-list", and then go all the way to the end of that row and paste the closing tag. And, just for my own sanity, I'm going to indent that row.

Okay, so... what does that do? If you refresh the page now, any navigation inside of this frame stays inside the frame. Watch! If I click "2"... that worked. It made an AJAX request for Page 2, our app returned that full HTML page - including the header, footer and all, but then Turbo Frame found the matching mix-browse-list <turbo-frame> inside of that, grabbed its contents, and put it here.

Yup, though it's not easy to see in this example, the only part of the page that's changing is this <turbo-frame> element. If I... say... messed with the title up here on my page, and then click down here and back to Page 2... that did not update that part of the page. Again, it works a lot like iframes, but without the weirdness. You could imagine using this, for example, to power an "Edit" button that adds inline editing.

But in our situation, this isn't very useful yet... because it works pretty much the same as before: we click the link, we see new results. The only difference is that clicking inside a <turbo-frames> doesn't change the URL. So no matter what page I'm on, if I refresh, I'm transported right back to Page 1. So this was kind of a step backwards!

But stick with me. I have a solution, but it's a little tricky at first. To start, I'm going to make the ID unique to the current page. Add a -, and then we can say pager.currentPage.

Then, down at the bottom, remove the Pagerfanta links and replace them with another Turbo Frame. Say {% if pager.hasNextPage %}, and inside of it, add a turbo-frame, just like above, with that same id="mix-browse-list-{{ }}". But this time, say pager.nextPage. Let me break this onto multiple lines here... and then we're also going to tell it what src to use for that. Oh, let me fix my typo... and then use another Pagerfanta helper called pagerfanta_page_url and pass that pager and then pager.nextPage. Finally, add loading="lazy".

Woh! Lemme explain, because this is kind of wild. First, one of the super-powers of a <turbo-frame> is that you can add a src attribute and then leave it empty. This tells Turbo:

Hey! I'm going to be lazy and start this element empty... maybe because it's a little heavy to load. But as soon as this element becomes visible to the user, make an Ajax request to this URL to get its contents.

So, this <turbo-frame> will start empty... but as soon as we scroll down to it, Turbo will make an AJAX request for the next page of results.

For example, if this frame is loading for page 2, the Ajax response will contain a <turbo-frame> with id="mix-browse-list-2". The Turbo Frame system will grab that from the Ajax request and put it here at the bottom of our list. And if there's a page 3, that will include yet another Turbo Frame down here that will point to Page 3.

This all might seem a but crazy, so let's try this out. I'm going to scroll up to the top of the page, refresh and... perfect! Now scroll down here and watch. You should see an AJAX request show up in the web debug toolbar. As we scroll... down here... ah! There's the AJAX request! Scroll down again and... there's a second AJAX request: one for Page 2 and one for Page 3. If we keep scrolling, we run out of results and reach the bottom of the page.

If you're new to Turbo Frames, that concept may have been a little confusing, but you can learn more on our Turbo tutorial. And a shout-out to an AppSignal's blog post that introduced this cool concept.

All right, team! Congrats on finishing the Doctrine course! I hope you're feeling powerful. You should be! The only missing part of Doctrine now is Doctrine Relations: being able to associate one entity to another through relationships, like many-to-one and many-to-many. We'll cover all of that in the next tutorial. Until then, if you have any questions or have a great riddle you want to ask us, we're here for you in the comments section. Thanks a lot, friends! See you next time!

Leave a comment!

Login or Register to join the conversation
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": "*",
        "babdev/pagerfanta-bundle": "^3.7", // v3.7.0
        "doctrine/doctrine-bundle": "^2.7", // 2.7.0
        "doctrine/doctrine-migrations-bundle": "^3.2", // 3.2.2
        "doctrine/orm": "^2.12", // 2.12.3
        "knplabs/knp-time-bundle": "^1.18", // v1.19.0
        "pagerfanta/doctrine-orm-adapter": "^3.6", // v3.6.1
        "pagerfanta/twig": "^3.6", // v3.6.1
        "sensio/framework-extra-bundle": "^6.2", // v6.2.6
        "stof/doctrine-extensions-bundle": "^1.7", // v1.7.0
        "symfony/asset": "6.1.*", // v6.1.0
        "symfony/console": "6.1.*", // v6.1.2
        "symfony/dotenv": "6.1.*", // v6.1.0
        "symfony/flex": "^2", // v2.2.2
        "symfony/framework-bundle": "6.1.*", // v6.1.2
        "symfony/http-client": "6.1.*", // v6.1.2
        "symfony/monolog-bundle": "^3.0", // v3.8.0
        "symfony/proxy-manager-bridge": "6.1.*", // v6.1.0
        "symfony/runtime": "6.1.*", // v6.1.1
        "symfony/twig-bundle": "6.1.*", // v6.1.1
        "symfony/ux-turbo": "^2.0", // v2.3.0
        "symfony/webpack-encore-bundle": "^1.13", // v1.15.1
        "symfony/yaml": "6.1.*", // v6.1.2
        "twig/extra-bundle": "^2.12|^3.0", // v3.4.0
        "twig/twig": "^2.12|^3.0" // v3.4.1
    "require-dev": {
        "doctrine/doctrine-fixtures-bundle": "^3.4", // 3.4.2
        "symfony/debug-bundle": "6.1.*", // v6.1.0
        "symfony/maker-bundle": "^1.41", // v1.44.0
        "symfony/stopwatch": "6.1.*", // v6.1.0
        "symfony/web-profiler-bundle": "6.1.*", // v6.1.2
        "zenstruck/foundry": "^1.21" // v1.21.0