Broadcasting Frontend Changes on Entity Update/Remove

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

In Review.stream.html.twig, we have the ability to publish turbo streams automatically whenever a review is created, updated or removed. That's pretty cool. Unrelated to this, I haven't mentioned it yet, but our site has a review admin area! You can get it by going to /admin/review. Here we can create, update or delete reviews. Do you... see where this is going? Sometimes an admin user will "tweak" a review to make it... um... more encouraging. Wouldn't it be cool if, when an admin user did this, that review was instantly updated on the frontend for all users?

Uh, yea! That would be cool! So let's do it.

Publishing an "update" Update

Start in _review.html.twig. This is the template that renders a single Review. Give this element an id so that we can target it from a turbo stream, how about id="product-review-{{ review.id }}".

<div
id="product-review-{{ review.id }}"
class="component-light my-3 p-3"
{% if isNew|default(false) %}
{{ stimulus_controller('streamed-item', {
className: 'streamed-new-item'
}) }}
{% endif %}
>
<p><i class="fas fa-user-circle me-2"></i>{{ review.owner.email }} <i class="fas fa-star ms-4"></i> {{ review.stars }}/5</p>
<div>
{{ review.content }}
</div>
</div>

Copy that value and, in Review.stream.html.twig, when the review is updated, let's add a new turbo stream: <turbo-stream> with action="replace" and target="" set to product-review-{{ review.id }}. Except that in this template, the variable is called entity.

Inside, add the boring - but required - template element and inside of that, include product/_review.html.twig. This template needs a review variable... so make sure to pass that in: review set to entity.

... lines 1 - 19
{% block update %}
<turbo-stream action="replace" target="product-review-{{ entity.id }}">
<template>
{{ include('product/_review.html.twig', {
review: entity
}) }}
</template>
</turbo-stream>
{% endblock %}
... lines 29 - 33

That's it! When a review is updated, it will replace this review element with the updated content.

Let's see it in action! Refresh the frontend. This is the review we'll update. Over in the admin area, add a very important dinosaur emoji... and save. Okay. I think that worked. Let me double check. Yep! Review updated.

Now check out the front end. That's amazing! This review just updated for every user in the world that is currently viewing a page where this is rendered. We could also update the quick stats area... but I'll leave that to you.

Removing a Review on Delete

What about removing a review? In the admin area, we can actually delete a review. Could we automatically remove this element from the frontend when that happens? Absolutely!

Inside the remove block, create a <turbo-stream>. This will have a new action - action="remove" - and will target the same element as our update. Now, you might expect me to say entity.id. But... by the time this template is rendered, the entity has already been deleted from the database. And so, entity.id is empty.

Fortunately, the library also passes us an id variable that we can use instead. Oh, and because we have action="remove", the turbo-stream element won't have anything inside: it's just an instruction to find this element and remove it.

... lines 1 - 29
{% block remove %}
<turbo-stream action="remove" target="product-review-{{ id }}"></turbo-stream>
{% endblock %}

Ok: refresh the frontend just to be safe... and in the admin area, delete this. Now... deep breath... switch to the frontend. It's gone! Ok, this is getting fun.

Fading out on Remove

So let's get fancier. What if, when a review is deleted, instead of instantly disappearing, the element turned red, then faded out. OoooOOOoo.

Start in styles/app.css. Add a new streamed-removed-item class that sets the background-color to coral.

... lines 1 - 184
.streamed-removed-item {
background-color: lightcoral;
}
... lines 188 - 191

Back in Review.stream.html.twig, this will be a bit trickier. We don't actually want to remove the element anymore... we want to keep it... but trigger some JavaScript that will fade it out.

To do this, change the action to replace... and then copy the entire template from update. But this time, pass in a new variable: isRemoved set to true. We can use that in the template to do something special.

... lines 1 - 29
{% block remove %}
<turbo-stream action="replace" target="product-review-{{ id }}">
<template>
{{ include('product/_review.html.twig', {
review: entity,
isRemoved: true
}) }}
</template>
</turbo-stream>
{% endblock %}

Go open it up: _review.html.twig. If we pass in an isNew variable, we already have code to activate a Stimulus controller that causes the item to get a green background that fades out. We're going to do something similar.

If isRemoved, then initialize that same Stimulus controller. But this time pass className set to streamed-removed-item. This is why we made that controller dynamic. Also pass in another value called removeElement set to true.

<div
id="product-review-{{ review.id }}"
class="component-light my-3 p-3"
{% if isNew|default(false) %}
{{ stimulus_controller('streamed-item', {
className: 'streamed-new-item'
}) }}
{% endif %}
{% if isRemoved|default(false) %}
{{ stimulus_controller('streamed-item', {
className: 'streamed-removed-item',
removeElement: true
}) }}
{% endif %}
>
<p><i class="fas fa-user-circle me-2"></i>{{ review.owner.email }} <i class="fas fa-star ms-4"></i> {{ review.stars }}/5</p>
<div>
{{ review.content }}
</div>
</div>

This will signal to the controller that we want to fade out the element entirely.

Let's get to work in that file: streamed-item_controller.js.

Start by setting up the removeElement value, which will be a Boolean.

Then, import a helper function called addFadeTransition. This is a utility that we created in the first tutorial to help us fade in or fade out an element.

To activate it, inside connect(), call addFadeTransition() and pass it this object, this.element - the element that we're going to fade - and also an options object with transitioned set to true. That's needed because our element will start visible and then we want it to fade out. If you want to know more about how this all works, check out our Stimulus tutorial.

Inside setTimeout(), check to see if this.removeElementValue is true. If it is not, then keep the original code: this is where we fade out the background color. But if it is true, call this.leave(). That will trigger the entire element to fade out.

import { Controller } from 'stimulus';
import { addFadeTransition } from '../util/add-transition';
export default class extends Controller {
static values = {
className: String,
removeElement: Boolean
}
connect() {
addFadeTransition(this, this.element, {
transitioned: true
});
this.element.classList.add(this.classNameValue);
setTimeout(() => {
if (this.removeElementValue) {
this.leave();
} else {
this.element.classList.add('fade-background');
this.element.classList.remove(this.classNameValue);
}
}, 5000);
}
}

Phew! Let's see this thing in action! Go back and find this review... here it is. Refresh the frontend to get the fresh CSS... delete the review... and go to the frontend! Yes! It's there but with a red background! And then... woohoo! It faded out!

The big takeaway here? By combining Turbo Streams with Stimulus, you can do much more than simply "update the HTML of an element". You can do... anything.

Okay team: there's just one more thing that I want to try: using Turbo Streams to pop up "toast" notifications on the frontend, like after we do something awesome. That's next.

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": "1.11.99.1", // 1.11.99.1
        "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.13.3
        "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
    }
}