Toast Notifications

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've made it to the last topic of the tutorial... so let's do something fun, like making it super easy to open "toast" notifications.

Toast notifications are those little messages that "pop up" like toast on the bottom - or top - of your screen. And Bootstrap has support for them. Our goal is simple but bold! I want to be able to trigger a toast notification from any template or from a Turbo Stream.

Creating the toast.html.twig Template

Start by creating a new template partial: _toast.html.twig. I'll paste in a structure that's from Bootstrap's documentation. Then let's make a few parts of this dynamic like {{ title }} - that's a variable we'll pass in... {{ when }} that defaults to just now and... for the body, {{ body }}.

<div class="toast" role="alert" aria-live="assertive" aria-atomic="true">
<div class="toast-header">
<svg class="rounded me-2" width="20" height="20" xmlns="http://www.w3.org/2000/svg" aria-hidden="true" preserveAspectRatio="xMidYMid slice" focusable="false"><rect width="100%" height="100%" fill="#007aff"></rect></svg>
<strong class="me-auto">Bootstrap</strong>
<small>11 mins ago</small>
<button type="button" class="btn-close" data-bs-dismiss="toast"
aria-label="Close"></button>
</div>
<div class="toast-body">
Hello, world! This is a toast message.
</div>
</div>

Next, open up product/_reviews.html.twig. After submitting a new review, we render a flash message. Now I want this to be a toast notification! Cool! Include that template instead... and pass in a couple of variables like title set to Success and body set to the actual flash message content.

... lines 1 - 6
<turbo-frame id="product-reviews-form">
... line 8
{% for flash in app.flashes('review_success') %}
{{ include('_toast.html.twig', {
title: 'Success!',
body: flash
}) }}
{% endfor %}
... lines 15 - 36
</turbo-frame>

The Toast Stimulus Controller

If we stopped now... congratulations! Absolutely nothing would happen. These toast elements are invisible until you execute some JavaScript that opens them on the page. To do that, we need a Stimulus controller!

Up in the assets/controllers/ directory, create a new file called, how about, toast_controller.js. Inside, give this the normal structure where we import Controller from stimulus, export our controller... and have a connect() method that, of course, logs a loaf of bread.

import { Controller } from 'stimulus';
export default class extends Controller {
connect() {
console.log('🍞');
}
}

Over in _toast.html.twig, I want to activate this controller whenever this toast element appears on the page. No problemo: on the outer element, add {{ stimulus_controller('toast') }}.

<div class="toast" role="alert" aria-live="assertive" aria-atomic="true" {{ stimulus_controller('toast') }}>
... lines 2 - 11
</div>

Our controller doesn't do anything yet, but let's at least make sure that it's connected. Head over to our site, refresh the page... make sure that your console is open... and then go fill out a new review. When we submit... got it! As soon as the toast HTML was rendered onto the page, our controller was initialized. Though... like I mentioned, you can't actually see the toast element yet. It's taking up some space... but it's invisible.

Let's fix that! Back in the controller, import Toast from bootstrap. Below add const toast = new Toast() and pass it this.element. To open the toast, say toast.show().

import { Controller } from 'stimulus';
import { Toast } from 'bootstrap';
export default class extends Controller {
connect() {
const toast = new Toast(this.element);
toast.show();
}
}

That's it! Refresh again and add another review. This time... that's super cool! And it means that we can, from anywhere, render the _toast.html.twig template and it will activate this behavior.

Grouping all the Toasts into One Container

Though... the positioning isn't what I was imagining. Before it disappeared, it was open... right in the middle of the page. I was hoping to put it in the top right corner of the screen.

To do that, we just need to add a few classes to the toast element. Except... there's one other minor problem. If you think about it, it's possible that a user could see multiple toast notifications at the same time. The toast system totally supports this.... it stacks them on top of each other. But for that to work, we need to have a single global "toast container" element on our page that all individual toasts live inside of.

This might be easier to show. Open up templates/base.html.twig. Really, anywhere, but I'll go to the bottom, add a <div> with id="toast-container. That could be anything: we'll use this id to find this element in JavaScript.

Also add class="toast-container" and a few other classes. toast-container helps Bootstrap stack any toasts inside of this... and everything else puts the toast in the upper right corner of the screen.

<!DOCTYPE html>
<html lang="en-US">
... lines 3 - 14
<body>
... lines 16 - 101
<div
id="toast-container"
class="toast-container position-fixed top-0 end-0 p-3"
></div>
</body>
</html>

Now, in order for this to work, we need all the toast notifications to physically live inside of this toast-container element. So basically, we need to render _toast.html.twig... and somehow get that HTML inside of the container.

But... I don't want to do that! I want to keep the flexibility of being able to render _toast.html.twig from... wherever and have it work. And we can still have this with a little help from our Stimulus controller.

Check it out: at the top of connect(), add const toastContainer = document.getElementById() and pass it toast-container to find the element that lives at the bottom of the page. Then... let's move ourselves into that: toastContainer.appendChild(this.element).

And now that it lives inside the container, we open it like normal!

Though... there is one subtle "catch". When the toast HTML initially loads, it will live here in the middle of the page. Naturally, Stimulus notices this element, instantiates a new controller instance and calls connect(). Yay! But when we move this.element into toast-container, Stimulus destroys the original controller instance, creates a new one, and calls connect() a second time.

In other words, the connect() method will be called twice: once when we originally render our toast element onto the page and again after we move into toast-container. Right now, that's going to cause an infinite loop where we call appendChild() over and over again.

To avoid that, add, if this.element.parentNode does not equal toastContainer. So only if the element has not been moved yet, move it... and then return. The first time this executes, it will move the element and exit. The second time it executes, it will skip all of this and pop open the toast.

import { Controller } from 'stimulus';
import { Toast } from 'bootstrap';
export default class extends Controller {
connect() {
const toastContainer = document.getElementById('toast-container');
if (this.element.parentNode !== toastContainer) {
toastContainer.appendChild(this.element);
return;
}
const toast = new Toast(this.element);
toast.show();
}
}

Let's try this thing! Refresh the page, add another review and... beautiful! If you quickly inspect the toast element... yup! It lives down inside of toast-container.

Publishing a Toast through Mercure to All Users

Ok, I have one last micro-challenge: whenever a new review is added to a product, I want to open a toast notification on every user's screen that's currently viewing the product. Something that says:

Hey! This product has a new review!

Over in Review.stream.html.twig, in the create block, add another turbo stream with action="append" and target=""... well... leave that empty for a minute. Give this the template element, include _toast.html.twig and pass in a few variables: title set to New Review and body set to

A new review was just posted for this product.

{% block create %}
... lines 2 - 18
<turbo-stream action="append" target="product-{{ entity.product.id }}-toasts">
<template>
{{ include('_toast.html.twig', {
title: 'New Review!',
body: 'A new review was just posted for this product'
}) }}
</template>
</turbo-stream>
{% endblock %}
... lines 28 - 49

Very nice! But... what should the target be? We could use toast-container. That would append it to this element. But... then the message would show up on every page. We only want this message to show up if you're viewing this specific product.

To do that, we need to target an element that only exists on this specific product's page. Open show.html.twig. Right inside of the product_body block, let's add an empty div with id="product-{{ product.id }}-toasts"

{% extends 'product/productBase.html.twig' %}
... line 2
{% block productBody %}
<div id="product-{{ product.id }}-toasts"></div>
... lines 5 - 49
{% endblock %}

A little empty element just for our toasts to go into. Copy this and, in Review.stream.html.twig, target it. Except that we need entity.product.id.

Let's check it out! Refresh the page... and then open the same product in another tab to "mimic" what a different user would see. Scroll down, fill in a review and... submit. Awesome! We have two toasts over here and... the other user sees the one toast! The two toast notifications in our first tab is a bit weird, but I'll leave it for now.

And... we're done! Woh! Congrats to you! You deserve a nice crisp high five... and maybe a short vacation for making it through this huge tutorial. It was huge because... well... Turbo has a lot to offer. I hope you're as excited about the possibilities of Stimulus and Turbo as I am.

Let us know what you're building. And, as always, if you have any questions, we're here for you down in the comments section.

All right, friends. See you next time!

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
    }
}