Dispatching a Message inside a Handler?

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

Deleting an image is still done synchronously. You can see it: because I made it extra slow for dramatic effect, it takes a couple of seconds to process before it disappears. Of course, we could hack around this by making our JavaScript remove the image visually before the AJAX call finishes. But making heavy stuff async is a good practice and could allow us to put less load on the web server.

Let's look at the current state of things: we did update all of this to be handled by our command bus: we have a DeleteImagePost command and DeleteImagePostHandler. But inside config/packages/messenger.yaml, we're not routing this class anywhere, which means it's being handled immediately.

Oh, and notice: we're still passing the entire entity object into the message. In the last two chapters, we talked about avoiding this as a best practice and because it can cause weird things to happen if you handle this async.

But... if you're planning to keep DeleteImagePost synchronous... it's up to you: passing the entire entity object won't hurt anything. And... really... we do need this message to be handled synchronously! We need the ImagePost to be deleted from the database immediately so that, if the user refreshes, the image is gone.

But, look closer: deleting involves two steps: deleting a row in the database and removing the underlying image file. And... only that first step needs to happen right now. If we delete the file on the filesystem later... that's no big deal!

Splitting into a new Command+Handler

To do part of the work sync and the other part async, my preferred approach is to split this into two commands.

Create a new command class called DeletePhotoFile. Inside, add a constructor so we can pass in whatever info we need. This command class will be used to physically remove the file from the filesystem. And if you look in the handler, to do this, we only need the PhotoFileManager service and the string filename.

So this time, the smallest amount of info we can put in the command class is string $filename.

... lines 1 - 2
namespace App\Message;
class DeletePhotoFile
{
... lines 7 - 8
public function __construct(string $filename)
{
... line 11
}
... lines 13 - 17
}

I'll hit Alt + enter and go to "Initialize Fields" to create that property and set it.

... lines 1 - 2
namespace App\Message;
class DeletePhotoFile
{
private $filename;
public function __construct(string $filename)
{
$this->filename = $filename;
}
... lines 13 - 17
}

Now I'll go to Code -> Generate - or Cmd+N on a Mac - to generate the getter.

... lines 1 - 2
namespace App\Message;
class DeletePhotoFile
{
private $filename;
public function __construct(string $filename)
{
$this->filename = $filename;
}
public function getFilename(): string
{
return $this->filename;
}
}

Cool! Step 2: add the handler DeletePhotoFileHandler. Make this follow the two rules for handlers: implement MessageHandlerInterface and create an __invoke() method with one argument that's type-hinted with the message class: DeletePhotoFile $deletePhotoFile.

... lines 1 - 2
namespace App\MessageHandler;
use App\Message\DeletePhotoFile;
... line 6
use Symfony\Component\Messenger\Handler\MessageHandlerInterface;
class DeletePhotoFileHandler implements MessageHandlerInterface
{
... lines 11 - 17
public function __invoke(DeletePhotoFile $deletePhotoFile)
{
... line 20
}
}

Perfect! The only thing we need to do in here is... this one line: $this->photoManager->deleteImage(). Copy that and paste it into our handler. For the argument, we can use our message class: $deletePhotoFile->getFilename().

... lines 1 - 2
namespace App\MessageHandler;
use App\Message\DeletePhotoFile;
... line 6
use Symfony\Component\Messenger\Handler\MessageHandlerInterface;
class DeletePhotoFileHandler implements MessageHandlerInterface
{
... lines 11 - 17
public function __invoke(DeletePhotoFile $deletePhotoFile)
{
$this->photoManager->deleteImage($deletePhotoFile->getFilename());
}
}

And finally, we need the PhotoFileManager service: add a constructor with one argument: PhotoFileManager $photoManager. I'll use my Alt+Enter -> Initialize fields trick to create that property as usual.

... lines 1 - 2
namespace App\MessageHandler;
use App\Message\DeletePhotoFile;
use App\Photo\PhotoFileManager;
use Symfony\Component\Messenger\Handler\MessageHandlerInterface;
class DeletePhotoFileHandler implements MessageHandlerInterface
{
private $photoManager;
public function __construct(PhotoFileManager $photoManager)
{
$this->photoManager = $photoManager;
}
public function __invoke(DeletePhotoFile $deletePhotoFile)
{
$this->photoManager->deleteImage($deletePhotoFile->getFilename());
}
}

Done! We now have a functional command class which requires the string filename, and a handler that reads that filename and... does the work!

Dispatching Embedded

All we need to do now is dispatch the new command. And... technically we could do this in two different places. First, you might be thinking that, in ImagePostController, we could dispatch two different commands right here.

But... I don't love that. The controller is already saying DeleteImagePost. It shouldn't need to issue any other commands. If we choose to break that logic down into smaller pieces, that's up to the handler. In other words, we're going to dispatch this new command from within the command handler. Inception!

Instead of calling $this->photoManager->deleteImage() directly, change the type-hint on that argument to autowire MessageBusInterface $messageBus. Update the code in the constructor... and the property name.

... lines 1 - 9
use Symfony\Component\Messenger\MessageBusInterface;
... line 11
class DeleteImagePostHandler implements MessageHandlerInterface
{
private $messageBus;
... lines 15 - 16
public function __construct(MessageBusInterface $messageBus, EntityManagerInterface $entityManager)
{
$this->messageBus = $messageBus;
$this->entityManager = $entityManager;
}
... lines 22 - 32
}

Now, easy: remove the old code and start with: $filename = $imagePost->getFilename(). Then, let's delete it from the database and, at the bottom, $this->messageBus->dispatch(new DeletePhotoFile($filename)).

... lines 1 - 5
use App\Message\DeletePhotoFile;
... lines 7 - 11
class DeleteImagePostHandler implements MessageHandlerInterface
{
... lines 14 - 22
public function __invoke(DeleteImagePost $deleteImagePost)
{
$imagePost = $deleteImagePost->getImagePost();
$filename = $imagePost->getFilename();
$this->entityManager->remove($imagePost);
$this->entityManager->flush();
$this->messageBus->dispatch(new DeletePhotoFile($filename));
}
}

And... this should... just work: everything is still being handled synchronously.

Let's try it next, think a bit about what happens if part of a handler fails, and make half of the delete process async.

Leave a comment!

This tutorial is built with Symfony 4.3, but will work well on Symfony 4.4 or 5.

What PHP libraries does this tutorial use?

// composer.json
{
    "require": {
        "php": "^7.1.3",
        "ext-ctype": "*",
        "ext-iconv": "*",
        "intervention/image": "^2.4", // 2.4.2
        "league/flysystem-bundle": "^1.0", // 1.1.0
        "sensio/framework-extra-bundle": "^5.3", // v5.3.1
        "symfony/console": "4.3.*", // v4.3.2
        "symfony/dotenv": "4.3.*", // v4.3.2
        "symfony/flex": "^1.1", // v1.4.4
        "symfony/framework-bundle": "4.3.*", // v4.3.2
        "symfony/messenger": "4.3.*", // v4.3.4
        "symfony/orm-pack": "^1.0", // v1.0.6
        "symfony/serializer-pack": "^1.0", // v1.0.2
        "symfony/validator": "4.3.*", // v4.3.2
        "symfony/webpack-encore-bundle": "^1.5", // v1.6.2
        "symfony/yaml": "4.3.*" // v4.3.2
    },
    "require-dev": {
        "symfony/debug-pack": "^1.0", // v1.0.7
        "symfony/maker-bundle": "^1.0", // v1.12.0
        "symfony/test-pack": "^1.0" // v1.0.6
    }
}