symfony/messenger
Symfony Messenger helps PHP applications send and receive messages to and from other applications or message queues. It offers transports, middleware, and message handlers for building scalable, asynchronous, event-driven systems, and integrates smoothly with Symfony or standalone PHP projects.
Version note: this assessment targets
symfony/messenger^8.1 (latest patchv8.1.8). Pin the minor (^8.1) — patch releases include behavioural bug fixes and security hardening (see Gotchas).New: [SECURITY] release
v5.4.54. Symfony published a security patch on the5.4LTS branch covering envelope/stamp deserialization (rejecting decoded messages that carry non-sendable stamps, validating stamps on unserialized envelopes, and restricting whatX-Message-Stamp-*headers may inject into an envelope). Security fixes are propagated across maintained branches, so treat this as "bump the latest patch on every branch you run" —>=5.4.54if you're still on LTS, latest patch of^8.1otherwise. There is no new API to adopt: the change hardens the exact stamp/serialization path Messenger relies on, so the practical impact is on edge-case behaviour, not setup (see Gotchas §1–3).
Install the package (patched patch level):
composer require symfony/messenger:^8.1
composer update symfony/messenger # confirm you're on the latest patch, not a cached 8.1.x
Configure transports. Symfony Messenger reads framework.messenger.* from the FrameworkBundle. In Laravel you supply that config yourself (a config/messenger.php normalised into the bus/transport factory):
// config/messenger.php
return [
'transports' => [
'sync' => ['dsn' => 'sync://'],
'async' => ['dsn' => 'redis://localhost:6379/messages'],
// or: 'dsn' => 'doctrine://default?queue_name=messenger'
],
'routing' => [
App\Messages\SendEmail::class => 'async',
],
'failure_transport' => 'failed',
'default_bus' => 'messenger.bus.default',
];
Define a message class (plain PHP object, no base class needed):
namespace App\Messages;
class SendEmail
{
public function __construct(public string $email, public string $subject) {}
}
Create a handler — attribute-discovered, so make sure the directory is autoloaded/scanned:
namespace App\Handlers;
use App\Messages\SendEmail;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
#[AsMessageHandler]
class SendEmailHandler
{
public function __invoke(SendEmail $message): void
{
Mail::to($message->email)->send(new WelcomeMail($message->subject));
}
}
Dispatch:
use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\Component\Messenger\Stamp\DelayStamp;
public function sendWelcomeEmail(MessageBusInterface $bus, string $email): void
{
$bus->dispatch(new SendEmail($email, 'Welcome!'));
// Delayed: stamps go in the *second* argument, in milliseconds
$bus->dispatch(new SendEmail($email, 'Reminder'), [new DelayStamp(3_600_000)]);
}
Run a worker. messenger:consume is a Symfony console command, not an Artisan one — either register it on Laravel's console kernel or wrap it:
php artisan messenger:consume async -vv --time-limit=3600 --memory-limit=256M
Create the Doctrine schema if you use the Doctrine transport:
php artisan messenger:setup-transports
Replace a Laravel job with a message when you need transport portability (Redis/Doctrine/AMQP/SQS), middleware pipelines, or explicit retry semantics:
$bus->dispatch(new SendEmail($email, 'Reminder'), [new DelayStamp(3_600_000)]);
Start by mirroring one existing queued job as a message + handler, run it on the sync transport in a test, then switch routing to async. If you already run Messenger in production, the first task after this release is not a feature — it's draining or verifying in-flight queues after the patch upgrade (see Gotchas §1).
Dispatching
MessageBusInterface::dispatch(object $message, array $stamps = []) returns an Envelope — read results from stamps on it.DelayStamp (ms), BusNameStamp, TransportNamesStamp (route a single message to a different transport), RedeliveryStamp (retries).$bus->dispatch($message, [new TransportNamesStamp(['async_priority'])]);
Handling
#[AsMessageHandler] with fromTransport, bus, priority, handles arguments.Transports
'doctrine' => ['dsn' => 'doctrine://default?queue_name=messenger&table_name=messenger_messages'],
read_write_timeout and use a dedicated Redis DB to avoid eviction/contention with cache.Failure handling
'failure_transport' => 'failed',
'transports' => [
'async' => [
'dsn' => 'redis://...',
'retry_strategy' => [
'max_retries' => 3,
'delay' => 1000, // ms
'multiplier' => 2,
'max_delay' => 0,
],
],
'failed' => ['dsn' => 'doctrine://default?queue_name=failed'],
],
RecoverableMessageHandlingException to force a retry regardless of strategy.UnrecoverableMessageHandlingException to send straight to the failure transport.messenger:failed:show, messenger:failed:retry, messenger:failed:remove.Middleware
handle(Envelope $envelope, StackInterface $stack): Envelope — not a generator (a common copy-paste error).use Symfony\Component\Messenger\Envelope;
use Symfony\Component\Messenger\Middleware\MiddlewareInterface;
use Symfony\Component\Messenger\Middleware\StackInterface;
class CorrelationMiddleware implements MiddlewareInterface
{
public function handle(Envelope $envelope, StackInterface $stack): Envelope
{
$envelope = $envelope->with(new CorrelationStamp((string) Str::uuid()));
return $stack->next()->handle($envelope, $stack);
}
}
HandleMessageMiddleware for dispatch-side effects, after it for post-handle effects.Batch processing
Symfony\Component\Messenger\Handler\BatchHandlerTrait with shouldFlush() / flush(); flush also happens on worker shutdown.--batch-style flushing, not routing config.$this->app->singleton(MessageBusInterface::class, fn ($app) => new MessageBus([
new AddBusNameStampMiddleware('messenger.bus.default'),
$app->make(SendMessageMiddleware::class),
$app->make(HandleMessageMiddleware::class), // handler locator from the container
]));
HandlersLocator to Laravel by scanning app/Handlers (or use AttributeLoader over a PSR-4 map).ConsumeMessagesCommand/FailedMessagesShowCommand in Illuminate\Console\Command subclasses, or register Symfony's command directly via $this->commands([...]) with the required services. Add Supervisor config per queue.config/messenger.php at in-memory:// (or sync://) in phpunit.xml and assert on the transport after dispatch:
$transport = $this->app->make(InMemoryTransport::class);
$bus->dispatch(new SendEmail('[email protected]', 'Hi'));
$this->assertCount(1, $transport->getSent());
v5.4.54 (security) makes stamp validation strict at decode time — audit your custom stamps before upgrading.
The release rejects decoded messages that carry non-sendable stamps and validates the stamps of unserialized envelopes. In practice:
CorrelationStamp/TenantStamp currently holds a closure, a resource, an Eloquent model, or an unserializable value, upgrade day is when you find out.Symfony\Component\Messenger\Stamp\StampInterface (a non-sendable stamp is a bug, not a shortcut).X-Message-Stamp-* headers are now restricted — don't hand-craft them.
The Serializer encodes stamps as X-Message-Stamp-<Class> entries in the encoded envelope's headers. The security release narrows what those headers are allowed to put into an envelope. Consequences:
Serializer::decode()/TransportMessageDecodingException semantics instead of constructing envelopes from arrays by hand.v8.1.8 changed serializer behaviour — watch in-flight messages.
PhpSerializer::getMessageType() no longer autoloads the class named in the envelope header (bug #66433). Two consequences:
type to your class (and keep the old class as an alias for one release).getMessageType() returning a class loaded as a side effect, load it explicitly.v8.1.8 also fixed InMemoryTransport delays across DST transitions (bug #66198). If your test suite fakes time around a DST boundary and asserts on DelayStamp/scheduled delivery, bump to ^8.1.8 — earlier patches could return delayed messages early or never. Don't write tests that pin a specific wall-clock offset across a DST change.
DelayStamp is milliseconds. new DelayStamp(3600) = 3.6 seconds. Use 3_600_000 for an hour. There is no $message->setDelay() — stamps only.
Failure transport config is top-level, not per-transport: 'failure_transport' => 'failed'. Setting it inside a transport array silently does nothing.
Doctrine transport needs setup, not migrations. Run messenger:setup-transports; it creates/updates messenger_messages. It is not an Artisan migration, so it won't run in your deploy migrate step unless you add it.
Workers are long-lived — code changes don't apply. Restart after deploy (messenger:stop-workers, or queue:restart if you've bridged it) — and this matters double for a security upgrade: a worker still running pre-patch code keeps the old, permissive decoding path alive. Cap with --time-limit and --memory-limit; set --sleep=0 only for high-throughput workers where you've measured CPU cost.
Serialization
Serializer (Symfony's symfony/serializer) instead of PhpSerializer.SerializerInterface::decode(array $encoded): Envelope / encode(Envelope $envelope): array — note it's Envelope in and out, not object.encode() is where stamps become X-Message-Stamp-* headers, a custom serializer that reorders/drops/renames stamp headers can now fail the hardened decode validation. Test encode→decode round-trips for every custom stamp in a unit test, not just happy-path dispatch.Retry semantics are transport-level. retry_strategy lives on the transport, and retries are driven by RedeliveryStamp. Don't invent a RetryStamp — it doesn't exist.
Idempotency / deduplication is not a core feature. Implement it with your own middleware + a unique constraint or Redis SETNX keyed on a stamp you add at dispatch. Transport-level "exactly once" isn't offered by any built-in transport — design handlers to be safely re-runnable.
Failure payloads are slimmer now. The same release drops trace args from FlattenException normalisation (#65746). Good for payload size and for not shipping serialized argument values into your failed table; bad if your triage workflow greps for argument data in stored exception traces — you'll need to log arguments yourself at the middleware/handler boundary.
Performance levers that actually exist: transport choice (sync for tests only), --sleep, worker concurrency (multiple processes, not a batch size), serialized payload size, and handler batching via BatchHandlerTrait. Batch size is your flush threshold, not a routing option.
Log at the middleware layer. Register a logging middleware that dumps get_class($envelope->getMessage()) plus all stamps — stamps carry the delay, retry count, and transport, which is where the answers usually are. This log is also your first signal of the new stricter validation: a spike in decode-time failures with no handler output points at stamps.
Inspect queues directly
SELECT * FROM messenger_messages WHERE queue_name = 'messenger' ORDER BY id; (there is no status column — failures live in the failure transport's queue).redis-cli LRANGE messages 0 -1 (Symfony's Redis transport keeps messages in a list plus a delayed zset — check ZRANGE messages.delayed 0 -1 WITHSCORES for anything stuck on a delay).Decoding errors after an upgrade usually mean either a class/header mismatch (pitfall 3) or a stamp now failing the hardened sendable/header validation (pitfalls 1–2). Reproduce with PhpSerializer::decode() on a dumped payload before touching worker config — that separates "our stamp is invalid" from "our class moved".
-vv on messenger:consume prints every received/handled/acked event — the fastest way to tell "never consumed" from "consumed and threw". On a security upgrade, also watch for messages that are consumed and then rejected before the handler runs; that's a decode failure, not a handler failure.
Extend, don't fork: custom retry behaviour → middleware around SendMessageMiddleware; custom routing → TransportNamesStamp at dispatch; custom failure policy → a middleware that catches and re-throws UnrecoverableMessageHandlingException. Don't patch the serializer to loosen stamp validation — that re-opens exactly what v5.4.54 closed.
How can I help you explore Laravel packages today?