Flag of Ukraine
SymfonyCasts stands united with the people of Ukraine

HTML-Returning Ajax Endpoint

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

When we type in the box, we are now making an Ajax request back to the server. The response - which we can see in the log - is the full HTML of the homepage. That's... not what we want. But... what do we want?

If you use something like React or Vue, your Ajax endpoints probably return JSON. You then use that JSON to build the HTML in JavaScript. But we're using Stimulus! And Stimulus is all about building HTML on the server.

So that's what we're going to do. But instead of a full page of HTML, we're going to return an HTML fragment: just the HTML needed for the "search suggestions" area.

Head over to ProductController and, before the return, add an if statement: if $request->query->get('preview'), then we know this is the suggestions Ajax request. Inside, render a new template: return $this->render() and call it product/_searchPreview.html.twig.

... lines 1 - 14
class ProductController extends AbstractController
... lines 17 - 20
public function index(Request $request, CategoryRepository $categoryRepository, ProductRepository $productRepository, Category $category = null): Response
... lines 23 - 28
if ($request->query->get('preview')) {
return $this->render('product/_searchPreview.html.twig', [
'products' => $products,
... lines 34 - 40
... lines 42 - 58

The template could be called anything. The _ at the front of the name is just a nice convention: I like to use it for any templates that render only part of a page. These are sometimes called partials.

Pass the template a products variable so we can render them. Be sure to add the s at the end of products... I'll find my mistake in a minute.

Now, create the template. In product, add a new file: _searchPreview.html.twig. But we're not going to extend a base template because we don't want a base layout. We're just going to start rendering content! I'll add a <div class="list-group"> to give this some markup that looks good in Bootstrap. Then {% for product in products %} and {% endfor %}.

<div class="list-group">
{% for product in products %}
... lines 3 - 10
{% endfor %}

Inside, I want each result to be a link. Add an a with href="" {{ path() }} and the name of the route to the product page, which is app_product. This route has an id wildcard. So pass id set to product.id. I'm also going to add a few more classes for styling... then inside the a, start with the simple {{ product.name }}.

Oh, and to be extra fancy, add an {% else %}. If there are no results, render a <div class="list-group-item"> with "No results found".

... line 1
{% for product in products %}
class="list-group-item list-group-item-action"
href="{{ path('app_product', { id: product.id }) }}"
{{ product.name }}
{% else %}
<div class="list-group-item">No results found!</div>
{% endfor %}
... lines 12 - 13

I love that: the entire search preview HTML in a simple template.

To see this, go back to the homepage but add a ?preview=1 to the URL. And... oh! Variable products does not exist. Because... in the controller, I forgot my "s".

Now... much better. I mean, it looks terrible here, but that's just because this page doesn't have any CSS. Head back to the homepage.

Adding the Ajax HTML to the Target

The last step in Stimulus is to dump the HTML from the Ajax endpoint onto the page. To control exactly where it goes, let's add a new target element. In index.html.twig, I want the content to go right below the input. Add a <div>... with class="search-preview". That's a class that already lives in our CSS that will help style things.

To make this a target, we need data-search-preview-target="" and... call the new target, how about, result. It doesn't need any content by default.

... lines 1 - 2
{% block body %}
... lines 4 - 37
... lines 39 - 53
... line 58
... lines 60 - 109
{% endblock %}

Over in our Stimulus controller, set up the target: static targets = [] an array with result inside.

... lines 1 - 2
export default class extends Controller {
... lines 4 - 7
static targets = ['result'];
... lines 9 - 18

Below, set the inner HTML on this. Copy the await response.text and replace it with this.resultTarget.innerHTML equals await response.text().

... lines 1 - 9
async onSearchInput(event) {
... lines 11 - 16
this.resultTarget.innerHTML = await response.text();
... lines 19 - 20

Done! Let's go try it! I'll click to go back to the homepage... just to clear the search entirely. Moment of truth: type. Ha! We got it! And if I type something nutty, no results found. It's alive!

Making the Search Preview Prettier

Let's celebrate by making it prettier.

Back over in the template - _searchPreview.html.twig - instead of rendering just the name, I'll paste in some markup. You can copy this from the code block on this page... but it's pretty basic.

... line 1
{% for product in products %}
... lines 4 - 5
<div class="d-flex align-items-center justify-content-between">
alt="{{ product.name }}"
src="{{ asset('/uploads/products/'~product.imageFilename) }}"
<span class="px-1">{{ product.name }}</span>
<em>{{ product.priceString|format_currency('USD') }}</em>
... lines 19 - 20
{% endfor %}
... lines 22 - 23

Move over and try it again. I actually didn't even need to refresh. Now type. Ah! Gorgeous! And you can click any of these to see that product.

Look back at the Stimulus controller. This whole feature took about 15 lines of JavaScript, only a couple lines of PHP and a very simple template that renders the results.

But it's not perfect yet. If I click off of the search area... it doesn't go away! We really need that to close. How can we do that? The easiest way is by leveraging a third party library that's full of behaviors for Stimulus, like "click outside" and "debounce". That's next.

Leave a comment!

Login or Register to join the conversation
Cyril Avatar

When my twig template uses a twitter bootstrap behaviour like "tooltip" or "popover", this behaviour is lost after an Ajax loading with "this.resultTarget.innerHTML = await response.text();"
Did you ever experiment this issue? Is there a way to fix it?

1 Reply

Hey Cyril S.

You can attach the bootstrap tooltip function to a parent element which is always present in your page, or, you can start using Stimulus in your site. If you want to learn more about using Stimulus you can watch our tutorial about Symfony UX https://symfonycasts.com/sc...


1 Reply
Cyril Avatar

I’m still using stimulus and axios to load and refresh this part of html. That’s why I’m surprised by this issue as stimulus is supposed to manage this sort of Ajax refresh habitual problem...

2 Reply

Ohh, you're using Stimulus, that's great! The problem relies on how you're loading bootstrap tooltip now. You can create a Stimulus controller an attach it to all elements that uses the tooltip

// tooltip_controller.js
import $ from 'jquery';
import 'bootstrap/js/dist/tooltip';

/* stimulusFetch: 'lazy' */
export default class extends Controller {
    connect() {


Cyril Avatar
Cyril Avatar Cyril | MolloKhan | posted 1 year ago | edited

Yes, it works!
As I'm using stimulus 2, bootstrap 5 and vanilla JS, I finally did:
import { Controller } from "stimulus";
import { Tooltip } from "bootstrap";

/ stimulusFetch: 'lazy' /
export default class extends Controller {
connect() {

  new Tooltip(this.element);

and in my template, to attach the elements, I used data-controller="bs-tooltip" instead of the bootstrap-classic data-bs-toggle="tooltip"

Thanks a lot Diego!

1 Reply

Nice job man! You managed to figure out the missing parts. Cheers!

SamuelVicent Avatar
SamuelVicent Avatar SamuelVicent | posted 1 year ago

We are trying to load html content with data-src image paths using lazy loading via native browser after an ajax call. But when we set the response data to this.resultTarget.innerHTML, the images are not loaded (do load if do not use lazy load). Do you know any hack for this issue? thanks a lot!


Hello SamuelVicent

As a tip do not use innerHTML for complex code, try to use insertAdjacentHTML or some library that will insert it and trigger correct DOM changes so every listener will be executed


SamuelVicent Avatar
SamuelVicent Avatar SamuelVicent | sadikoff | posted 1 year ago

First of all, thank your for answering :)

We also tryied insertAdjacentHTML and no luck: this.contentTarget.insertAdjacentHTML('beforeend', response.data.data.content);

The idea is using stimulus and force re-render contents from an ajax request without doing it manually or using additional libraries, having in mind that lazy load is a built-in feature in chrome and other browsers.

Just asking if you know any kind of setup for this to work automatically. Thanks a lot!


hm interesting question, I can think only about Turbo tech from Stimulus devs probably it's the best use-case for it, however I'm not fully sure about it.

Steven J. Avatar
Steven J. Avatar Steven J. | posted 2 years ago

I didn't know we can use else without an if in twig 😮

Jakub G. Avatar

oh my, me too,
so far best thing I've learned, thanks! : D


Hey Steven,

Yeah, this is a little-known feature from Twig :)


Cat in space

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

This tutorial works perfectly with Stimulus 3!

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.11.1
        "doctrine/doctrine-bundle": "^2.2", // 2.2.3
        "doctrine/doctrine-migrations-bundle": "^3.0", // 3.0.2
        "doctrine/orm": "^2.8", // 2.8.1
        "phpdocumentor/reflection-docblock": "^5.2", // 5.2.2
        "sensio/framework-extra-bundle": "^5.6", // v5.6.1
        "symfony/asset": "5.2.*", // v5.2.3
        "symfony/console": "5.2.*", // v5.2.3
        "symfony/dotenv": "5.2.*", // v5.2.3
        "symfony/flex": "^1.3.1", // v1.18.5
        "symfony/form": "5.2.*", // v5.2.3
        "symfony/framework-bundle": "5.2.*", // v5.2.3
        "symfony/property-access": "5.2.*", // v5.2.3
        "symfony/property-info": "5.2.*", // v5.2.3
        "symfony/proxy-manager-bridge": "5.2.*", // v5.2.3
        "symfony/security-bundle": "5.2.*", // v5.2.3
        "symfony/serializer": "5.2.*", // v5.2.3
        "symfony/twig-bundle": "5.2.*", // v5.2.3
        "symfony/ux-chartjs": "^1.1", // v1.2.0
        "symfony/validator": "5.2.*", // v5.2.3
        "symfony/webpack-encore-bundle": "^1.9", // v1.11.1
        "symfony/yaml": "5.2.*", // v5.2.3
        "twig/extra-bundle": "^2.12|^3.0", // v3.2.1
        "twig/intl-extra": "^3.2", // v3.2.1
        "twig/twig": "^2.12|^3.0" // v3.2.1
    "require-dev": {
        "doctrine/doctrine-fixtures-bundle": "^3.4", // 3.4.0
        "symfony/debug-bundle": "^5.2", // v5.2.3
        "symfony/maker-bundle": "^1.27", // v1.30.0
        "symfony/monolog-bundle": "^3.0", // v3.6.0
        "symfony/stopwatch": "^5.2", // v5.2.3
        "symfony/var-dumper": "^5.2", // v5.2.3
        "symfony/web-profiler-bundle": "^5.2" // v5.2.3

What JavaScript libraries does this tutorial use?

// package.json
    "devDependencies": {
        "@babel/preset-react": "^7.0.0", // 7.12.13
        "@popperjs/core": "^2.9.1", // 2.9.1
        "@symfony/stimulus-bridge": "^2.0.0", // 2.1.0
        "@symfony/ux-chartjs": "file:vendor/symfony/ux-chartjs/Resources/assets", // 1.1.0
        "@symfony/webpack-encore": "^1.0.0", // 1.0.4
        "bootstrap": "^5.0.0-beta2", // 5.0.0-beta2
        "core-js": "^3.0.0", // 3.8.3
        "jquery": "^3.6.0", // 3.6.0
        "react": "^17.0.1", // 17.0.1
        "react-dom": "^17.0.1", // 17.0.1
        "regenerator-runtime": "^0.13.2", // 0.13.7
        "stimulus": "^2.0.0", // 2.0.0
        "stimulus-autocomplete": "^2.0.1-phylor-6095f2a9", // 2.0.1-phylor-6095f2a9
        "stimulus-use": "^0.24.0-1", // 0.24.0-1
        "sweetalert2": "^10.13.0", // 10.14.0
        "webpack-bundle-analyzer": "^4.4.0", // 4.4.0
        "webpack-notifier": "^1.6.0" // 1.13.0