$bus->dispatch(new SendWelcomeEmail($user->getId())); and then, in the same request, a handler:

class SendWelcomeEmailHandler
{
    public function __invoke(SendWelcomeEmail $message)
    {
        // load user by id, send the email
    }
}

That is Messenger in Symfony 4.1, still marked experimental. By default everything is synchronous, so at first it is a function call with extra steps.

The extra steps are for the transport. Change configuration, route this message class to AMQP, and the same handler runs in a worker process. The calling code does not change. Start synchronous, go async when you need it. I like this order much more than “install RabbitMQ on day one”.

Two rules follow from the design, and both bite when ignored.

The message travels, so it gets serialized. Put ids in it, never Doctrine entities. An entity in a message looks fine in sync mode and explodes the day you flip the transport, or worse, quietly serializes a detached object graph.

A failed message goes back to the queue and the handler runs again. Sending the welcome email twice is embarrassing. Charging twice is a disaster. Make handlers idempotent from the start, while everything is still synchronous and it looks unnecessary.

And the thing no config flag hides: async changes the consistency model. After dispatch() returns, in async mode nothing has happened yet. Code below that line cannot assume the email exists, the projection is updated, the PDF is generated. The framework makes sync-to-async a one-line change. Your assumptions are not switched by config.

Flip one message to async early, in staging, to find out which assumptions you have. I flipped one last week. I had four.