Flag of Ukraine
SymfonyCasts stands united with the people of Ukraine

Publishing Mercure Updates in PHP

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 now know that we can easily subscribe to a Mercure topic in JavaScript. And, if we publish a message to that topic with <turbo-stream> HTML in it, our JavaScript will instantly notice & process it. Sweet!

So far, we've published messages to Mercure via curl at the command line... but that was just to get a feel for how it works. In reality, we're going to publish message from PHP... which is a heck of a lot simpler anyways.

Copy the <turbo-stream>... then go find ProductController... and the reviews action.

Publishing a Message from PHP

To publish updates to a Mercure Hub, we need to autowire a new service. Use HubInterface and I'll call it $mercureHub.

Down below, to start, let's publish an update when we submit the form... but not necessarily when it's successful. I'm lazy: this will let us test without filling out the form successfully. Add a variable - $update - set to new Update() - a handy class for creating messages. We need to pass this two arguments. The first is the topic or topics that we want to publish to. Use product-reviews. The second is the data that we want to send. Paste in the <turbo-stream> string.

Below, to publish this, all we need is $mercureHub->publish($update).

... lines 1 - 20
class ProductController extends AbstractController
{
... lines 23 - 71
/**
* @Route("/product/{id}/reviews", name="app_product_reviews")
*/
public function productReviews(Product $product, CategoryRepository $categoryRepository, Request $request, EntityManagerInterface $entityManager, HubInterface $mercureHub)
{
... lines 77 - 82
if ($request->isMethod('POST')) {
... lines 84 - 85
$update = new Update(
'product-reviews',
'<turbo-stream action="update" target="product-quick-stats"><template>QUICK STATS CHANGED!</template></turbo-stream>'
);
$mercureHub->publish($update);
... lines 91 - 109
}
... lines 111 - 117
}
... lines 119 - 126
}

Kind of... beautiful, isn't it?

Let's try this! Find your browser and refresh so the quick stats area is restored. Scroll down and submit the form empty. Uh... 500 error? Open the profiler for that request. Hmm:

Failed to send an update

Setting verify_peer to False in dev for Macs

Not... very explanatory. But notice that there were four exceptions. When this happens, it's often one of the other exceptions that has more details. Ah:

SSL peer certificate or SSH remote key was not okay

This... is a problem specific to the Symfony binary web server, https and... Macs. You can learn more about it on this issue for the Symfony CLI. If you're not using a Mac, good for you! That hopefully just worked.

If you are, the easiest way to fix this is to disable "peer verification" in the dev environment.

To do this, open config/packages/framework.yaml. At the bottom, use when@dev to set config specific to the dev environment - that's a feature that's new to Symfony 5.3. Under this, set framework, http_client, default_options then verify_peer: false.

... lines 1 - 25
when@dev:
framework:
http_client:
default_options:
verify_peer: false

That's not something you want to set in production... and it's a bummer we need to set it in the dev environment. But it should fix our issue.

Close this... then refresh the page again. Scroll down... and submit the review form. Ok! We get the normal validation error - that's expected. But scroll up. Yes! We just updated the page with our stream through Mercure! That's awesome!

So next: let's use this new superpower to simplify our reviews action. We can now redirect on success like we originally were... and publish a stream to update the quick stats area.

Leave a comment!

8
Login or Register to join the conversation
Kez Avatar
Kez Avatar Kez | posted 1 month ago | edited

So I'm getting the following error "Key provided is shorter than 256 bits, only 80 bits provided" anyone else had this error?

Reply

Hey Kez,

Not something I noticed myself, but maybe our users might face it and give some hints. Could you share a bit more information about what exactly you're doing when seeing this error? Right now it's difficult to say.

Cheers!

Reply
Fabrice Avatar

Hello ! And imagine that we only want to send an update to one or some users?

Imagine a blog. An admin arbitrarily edits the content of a post, and in this case we only want to notify the original author of the post, how should we go about dynamically targeting users who will receive the updates?

Reply

Hey Fabrice!

Sorry for the very slow reply - summer vacations :). I'm not an expert on this, but I believe this needs to be done by creating very targeted topics. For example, you could create a topic called user_{id} where {id} is the dynamic id of a user. Then, to notify that user of something, you publish to that specific topic. You would make that a private topic so that only that specific user can listen to it.

If you have 1000 concurrent users on your site, then it means you'll have 1000 topics being listened/connected to. That, by itself, I don't think is a problem - if you look at the Mercure managed hosting as a guide - https://mercure.rocks/pricing - this would mean that you have 1000 concurrent connections. But that is no different than if you had just ONE topic and all 1000 users are connected to it. So what I mean to say is: I don't think having many topics is a problem.

But there are two things that might become a problem... and they are related:

Suppose every user is listening to a user_{id} topic. Now suppose that someone comments on a post, and we want to notify everyone who has also commented on that post. How do we do that? We have 2 options, and both have pros/cons and highlight the challenge:

A) We could loop over all users who have commented on the post (e.g. 25) and send an update to the user_{id} topic of each one. But, that means 25 updates... which is a lot of work to do (you probably will need to do it via Messenger to avoid sending 25 POST requests from your app while the user is waiting for their comment to be posted) and Mercure (depending on your install) could get overwhelmed/limited if you send TOO many requests too quickly (the pricing page talks about 30 POST request per minute for the "Hobby" plan, for example).

B) OR, you could have everyone who has commented (e.g. 25 users) ALSO listening to a post_{post_id} topic. The problem is that if a user has commented on 5 posts, then they are now listening to 6 topics (user_{id} + 5x post_{post_id}). That might be a lot for one user to be listening too - I'm not sure what performance problems that user might have. But perhaps more importantly, if 1000 concurrent users on your site have each commented on 5 posts, then you now have 6000 concurrent connections to Mercure. Yikes!

So choosing your topics is an art and depends on your project. There may be some tricks that i'm not aware of - but this is my current impression.

Cheers!

1 Reply
Fabrice Avatar

Thank you for your detailed answer! In fact, I would have to be very careful.

I saw that you were planning to do some training on Mercury in the future, this may be an opportunity for you to talk about it :)

Reply
Nick-F Avatar

I have a question on a possible use case for streams.
I have a a backend dashboard for an inventory management system with over 100,000 and entity relations in a database.
I have a page on the backend where a user can upload an excel file to update the database.
1. Upload an xlsx file (this has over 70,000 rows)
1.a. Xlsx file is saved to the server
1.b Xlsx file is read and converted to csv file which is saved to server
2. Sweet Alert modal pops up when file upload completes
3. User clicks confirm to begin the database update
4. The sweet alert modal makes an ajax call to a symfony controller that handles updating the database from the uploaded file
5. While waiting for the controller response, a loading spinner displays in the modal.
6. About 8 minutes later, the controller returns a response and the sweet alert displays the success message.

The spinner is cool for maybe a 10 second request, but for long processes I need a loading bar.
Is there a way to use streams to render a progress bar in the modal?

Reply

Hey Nick F.!

That's a very interesting situation :). I think there are two possible ways to handle it. But in both cases, if you're going to have a progress bar, you'll first need to make sure you have a way to check/track the progress on the server. For example, if you upload an xlsx file with 70,000 rows, then (somewhere) you need to keep track of how many rows have been processed so that you can get a "percentage complete". That's... probably a good thing to have anyways, in case the process fails so that you can know how many rows were processed and which rows were unprocessed.

Anyways, assuming you can calculate the percentage complete, then there are two ways to show it to the user:

A) Polling: this is simple, but effective. You setup a Stimulus controller that makes an Ajax call every 10 seconds (or whatever you want). That would return the "% complete", either as JSON or as pre-rendered "loading bar" HTML for that percentage.

B) Streams. First, let's assume that you assign each "upload" a job number - e.g. 123. You could then listen to, for example, an "import_123" Mercure channel. Then, in the code that processes the rows, you could dispatch an Update to Mercure on this channel that could "replace" the existing progress bar element with an updated one set at the new percentage. For 70,000 rows, maybe you only dispatch this message every 100 rows to avoid sending a ton of unnecessary updates.

Really, for this situation, both are fine: polling is easier, and actually fits better in this situation than other situations (because you don't care that the user is notified the EXACT instant that you go from 10% to 11%, updating the progress bar every 10 seconds works perfectly fine).

Somewhat related, you mentioned:

> 4. The sweet alert modal makes an ajax call to a symfony controller that handles updating the database from the uploaded file
> 5. While waiting for the controller response, a loading spinner displays in the modal.
> 6. About 8 minutes later, the controller returns a response and the sweet alert displays the success message.

It sounds like that Ajax call will actually be processing for all 8 minutes. That's possible in PHP, but a little odd - and it ties up one of your php-fpm processes for all 8 minutes (not a huge deal maybe, but something to keep in mind). If you're able, this is a process that would be served really well with Messenger. You could still make an Ajax call to start the process, but that would just dispatch a message to Messenger... which would then be handled async by 1 or more worker processes. The progress bar updating would work exactly the same.

Cheers!

Reply
Nick-F Avatar

Failed to send update also shows up on windows

Reply
Cat in space

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

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