How To Prioritize Messages When Building Asynchronous Applications With Symfony Messenger

· Thibaut Chieux · 7 minutes to read
Blue sign on a building with several Now What? letters

Asynchronous processing offers benefits like decoupled processes and faster response times, but managing message priorities can become a challenge. When dealing with tasks ranging from password resets to complex exports, ensuring timely delivery of critical messages is essential. This article explores common asynchronous processing issues and provides solutions using Symfony Messenger, allowing you to optimize your application without extensive refactoring.

Going asynchronous sounds like a dream: decoupled processes, faster response times, and no more users staring at spinning wheels. But pretty quickly, reality hits — some messages take forever, others are way too important to be delayed, and suddenly you’re drowning in a swamp of priorities.

Whether you're firing off password reset emails or triggering complex exports, you need to make sure the right messages get through at the right time. This article dives into the problems you’ll face — and how to solve them using Symfony Messenger, without rewriting your app from scratch or crying into your logs at 3 AM.

The problem: prioritize dynamically every message

Animated gif with a lady with red glasses talking with the text "We have a priorities problem" belowWhen you start queueing messages in your Symfony app, one thing becomes obvious real fast: not all messages are equal. Some are critical and time-sensitive. Others… not so much.

Some transports already offer a way to handle priorities like:

🐰 RabbitMQ has x-priority

🌱 Beanstalkd has built-in tube priority

Nice — but what if I want to switch to another transport tomorrow without rewriting half my code?

Symfony Messenger has a way

The official documentation shows how to split messages into multiple transports based on priority. Think of it like assigning lanes on a highway: one for ambulances, one for scooters.

framework:
    messenger:
        transports:
            async_priority_high:
                dsn: '%env(MESSENGER_TRANSPORT_DSN)%'
                options:
                    queue_name: high
            async_priority_medium:
                dsn: '%env(MESSENGER_TRANSPORT_DSN)%'
                options:
                    queue_name: medium
            async_priority_low:
                dsn: '%env(MESSENGER_TRANSPORT_DSN)%'
                options:
                    queue_name: low
            async_priority_very_low:
                dsn: '%env(MESSENGER_TRANSPORT_DSN)%'
                options:
                    queue_name: very_low

        routing:
            'App\Message\ExportMessage': async_priority_low
            'App\Message\UpdateStateMessage': async_priority_high

💡 Note: The queue names are up to you — just make sure they reflect your actual use case. These are fictional examples.

Now that we’ve split messages by priority, consuming them in the right order is just as important. Thankfully, Symfony Messenger makes this super easy:

php bin/console messenger:consume async_priority_high async_priority_medium async_priority_low async_priority_very_low

The worker will first consume from async_priority_high. If it’s empty, it will try the next one. And so on. So even if there’s a backlog of non-urgent messages, high-priority ones don't get stuck waiting behind them.

How do I choose the right queue?

This was honestly the trickiest part for me. There’s no one-size-fits-all formula, and this table is crucial — everything else in this article depends on it.

👉 It obviously needs to be filled with the client or the product owner and not just dev gut feelings.

The question you’re answering here is simple, but powerful:

“What’s the maximum acceptable delay (including queue time and actual handling) for each kind of message?”

And from there, you get your priority mapping:

Priority

High

Medium

Low

Very low

Time before handling

1 minute

10 minutes

1 hour

1 day

Example

- State update

- Email

CMS update

- Prices update

- Analytics processing

- Export

- Anonymization

Now that I’ve split the messages into queues, everything should be perfect, right?

Well… not quite.

🙃 One problem remains: message types can be too generic

Let’s say I have an EmailMessage. Sounds fine. But I might use it for:

  • A password reset 🟥 High priority

  • A delivery notification 🟨 Medium

  • A “rate your purchase” ping 🟦 Low or very low

So... how do I assign a transport when the same message class can represent totally different levels of urgency?

Another problem : A message should be able to have more than one priority

Enter: TransportNamesStamp and our custom PriorityStamp

Luckily, Symfony Messenger already has a built-in way to force a message to go to a specific transport: TransportNamesStamp. But to make things cleaner (and more semantic), let’s introduce our own PriorityStamp:

namespace App\Messenger\Stamp;

use Symfony\Component\Messenger\Stamp\StampInterface;

readonly class PriorityStamp implements StampInterface
{
    public function __construct(private string $priority) {}

    public function getPriority(): string
    {
        return $this->priority;
    }
}

And now, a custom middleware to hook into the dispatch flow:

readonly class PriorityRoutingMiddleware implements MiddlewareInterface
{
    public function handle(Envelope $envelope, StackInterface $stack): Envelope
    {
        // Check if the message has a PriorityStamp
        $priorityStamp = $envelope->last(PriorityStamp::class);

        if ($priorityStamp instanceof PriorityStamp) {
            $priority = $priorityStamp->getPriority();

            // Determine the transport based on priority
            $transport = match ($priority) {
                'high' => 'high_priority',
                'medium' => 'medium_priority',
                'low' => 'low_priority',
                'very_low' => 'very_low_priority',
                default => throw new \RuntimeException('Unknow priority level')
            };

            // Add a TransportNamesStamp to redirect the message
            $envelope = $envelope->with(new TransportNamesStamp([$transport]));
        }

        return $stack->next()->handle($envelope, $stack);
    }
}

Then, don’t forget to add our new custom middleware to the messenger configuration:

framework:
    messenger:
      # ...
      buses:
        messenger.bus.default:
          middleware:
            - 'App\Messenger\Middleware\PriorityRoutingMiddleware'

Now, when sending a message, I can easily override its priority — no need to create a new message class or refactor everything.

$this->messageBus->dispatch(
    new SendEmailMessage($notificationEmail),
    [new PriorityStamp('medium')],
)

In this case, we’re saying:

“Hey, this email isn’t that urgent — 10 minutes is fine.”

This keeps critical messages flowing fast, without flooding your high-priority queue with lower-stakes noise.

So where are we now?

✅ I can dynamically choose the transport

✅ I can adjust the priority at dispatch time

❌ I can ensure every message is handled within its max allowed time ← Still not there yet.

So… what’s the problem now?

Let’s say I send a message to update the price of every product variant in my catalog — or a large subset.

Not a big deal if I’ve got a few product variants. But if I have 100,000 variants? 500,000 variants? A million ? And each one makes a remote API call to fetch the price?

Here what happens:

📨 A message enters the queue.

🧠 It starts processing.

It takes 5, 10, 30… 60 minutes.

🧵 Meanwhile, one PHP worker is stuck.

Moone Boy watching his watch sitting on the ground with a suitcase next to himI could try to batch those API calls. Sure, that helps — but it only reduces the problem. It doesn’t solve it. Not in a way that truly scales.

And that’s a problem, even with all our beautiful prioritization logic — because long-running messages don’t play well in this model.

Last problem: Some messages take ages to be handled

Let’s say I have this message and corresponding handler in my project corresponding to the variant price update process:

readonly class UpdatePrices
{
    public function __construct(
        public array $filters,
    ) {
    }
}
#[AsMessageHandler]
readonly class UpdatePricesHandler
{
    public function __invoke(UpdatePrices $message): void
    {
        foreach ($this->productVariantRepository->findAllByRegex($message->filters) as $product) {
            $this->priceUpdater->updatePriceForVariant($variant);
        }
        
        $this->em->flush();
    }
}

Let’s be honest: the problem screams at us.

If 200,000 variants match this regex, and each update takes 0.1 second, that’s ~5 hours of processing in one go — way beyond our 1-hour limit.

To avoid jamming the queue, we’ll break this job into smaller messages. Let’s go to the extreme: one message = one variant update.

Keep the main message:

readonly class UpdatePrices
{
    public function __construct(
        public array $filters,
    ) {
    }
}

Add a unitary one:

readonly class UpdateVariantPrice
{
    public function __construct(
        public int $variantId,
    ) {
    }
}

Now change the handler to dispatch one message per variant:

public function __invoke(UpdatePrices $message): void
{
	foreach ($this->variantRepository->findByComplexQuery($message->filters) as $variant) {
        $this->messageBus->dispatch(new UpdateVariantPrice($product->getId()));
    }
}

Bonus: we can even plug in our dynamic priority logic:

public function __invoke(UpdatePrices $message): void
{
	foreach ($this->variantRepository->findByComplexQuery($message->filters) as $variant) {
    	if($variant->isSoldVeryOften()) {
        	$this->messageBus->dispatch(
            	new UpdateVariantPrice($variant->getId()),
            	[new PriorityStamp('medium')]
            );
        } else {
        	$this->messageBus->dispatch(new UpdateVariantPrice($variant->getId()));
        }
    }
}

And the new handler for the unitary message:

public function __invoke(UpdateVariantPrice $message): void
{
    $variant = $this->variantRepository->find($message->variantId);
    if (!$variant instanceof ProductVariant) {
        throw new UnrecoverableMessageHandlingException("Impossible to find the variant");
    }
    
    $this->priceUpdater->update($variant);
    $this->em->flush();
}

Sure, the total time may increase slightly, but the queue stays fluid. If a higher-priority message comes in, it’s picked up right away.

So what about now :

I can dynamically choose the transport

I can adjust the priority at dispatch time

I can ensure every message is handled within its max allowed time ← Still not there yet.

…Wait. Nothing changed?

Anakin Skywalker angry screaming "Liar"Let’s say I’ve sent:

  • 3 price updates

  • 10 exports

  • Hundreds of state updates

  • A few anonymizations

Do we need to scale?

Probably. But that’s a topic for another article — others are way more qualified than me to go deep on scaling strategies.

Still, here’s my two cents:

  • If you scale based on the number of pending messages, assign a weight per priority. (E.g. I count 1 high message as 1,200 very low ones.)

  • It won’t be perfect at first. Monitoring is your friend.

  • When downscaling, use hysteresis to avoid flapping between too many and too few workers.

Now we're talking

Now I can finally say it, for real:

I am able to prioritize dynamically and ensure that every message is handled during a given time period.

Prioritization cheat sheet

You want your messages to be processed in time? Prioritize.

  • Split your messages by priority: Define queues like high, medium, low, and very_low. Consume them in order.

  • Route dynamically with a stamp: Using TransportNamesStamp or a custom one.

  • Break big tasks into small ones: Don’t let a single message hog a worker for hours. Split it into smaller ones, and dispatch them.

In conclusion

Prioritizing messages in Symfony Messenger isn’t plug-and-play, but it’s not rocket science either. With a bit of planning, some custom code, and a mindset focused on time-to-handle (not just throughput), you can build a system where important things get done first — without choking the rest.

And once you’ve got that running? You’re not just dispatching messages anymore — you’re orchestrating flow.

Ready to optimize your Symfony application performance?

Implement message prioritization today with the team of the creator of Symfony and ensure your most critical tasks are always handled first.

This might also interest you

Fabien Potencier
Elise Hamimi

SymfonyCon Amsterdam 2025: Our Recap and the Highlights

After an iconic first edition in 2019, SymfonyCon made its big comeback to Amsterdam. From the start, you could feel the energy of a highly anticipated conference: more than 1,200 attendees, 39 nationalities, the biggest Symfony community reunion of the year, great discoveries... and a fun atmosphere. This year was extra special because it was the 20th anniversary of Symfony. SensioLabs was there: we'll tell you all about our experience there!

Read more
The SensioLabs team celebrating the 20th anniversary of Symfony with balloons
Jules Daunay

The Story Continues: SensioLabs Celebrates Symfony's 20th Anniversary

Time flies, especially when you're busy shaping the future of development! The SensioLabs team has just reached a milestone with the anniversary of the Symfony framework. We marked the occasion at the office, but the party isn't over yet. The date is already set for an XXL celebration at SymfonyCon Amsterdam 2025, from November 27 to 28.

Read more
PHP 8.5 URI extension
Oskar Stark

PHP 8.5's New URI Extension: A Game-Changer for URL Parsing

PHP 8.5 introduces a powerful new URI extension that modernizes URL handling. With support for both RFC 3986 and WHATWG standards, the new Uri class provides immutable objects, fluent interfaces, and proper validation - addressing all the limitations of the legacy parse_url() function. This guide shows practical before/after examples and explains when to use each standard.

Read more
Open in new tab
Silas Joisten

The Tab Trap: Why Forcing New Tabs Is Bad UX

We’ve all done it — added target="_blank" to a link to “help users” stay on our site. But what feels like a harmless convenience often creates confusion, breaks accessibility, and introduces hidden security risks.

Read more
3 dog heads
Mathieu Santostefano

Bring Your Own HTTP client

Break free from rigid dependencies in your PHP SDKs. Learn how to use PSR-7, PSR-17, and PSR-18 standards along with php-http/discovery to allow users to bring their favorite HTTP client, whether it's Guzzle, Symfony HttpClient, or another. A must-read for PHP and Symfony developers.

Read more
SensioLabs University Courses announcing the new level 3 Master training course now available
Jules Daunay

Master Symfony: Unlock Expert Skills with Our Training

Take your Symfony proficiency from good to great with the new Level 3 training course at SensioLabs! Master complex topics, optimize performance, and become a Symfony expert.

Read more
Two images: on the left many cars stuck in a traffic jam with the sign "All directions" above, on the right a blue car moving forward alone on the highway with the sign "Service Subscriber" and a Symfony logo above
Steven Renaux

Symfony Lazy Services with Style: Boost DX using Service Subscribers

Boost your Symfony app's performance and developer experience! Learn how to use Service Subscribers and traits for lazy service loading to reduce eager instantiation, simplify dependencies, and create modular, maintainable code.

Read more
Poster of Guillaume Loulier presentation
Salsabile El-Khatouri

A Symfony Training at SensioLabs: Behind The Scenes

What does Symfony training at SensioLabs look like? Find out in this interview with Guillaume Loulier, a passionate developer and trainer, who tells us all about the official Symfony training courses.

Read more
Image