Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Messenger Laravel Package

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.

View on GitHub
Deep Wiki
Context7

Getting Started

Minimal Setup in Laravel

Version note: this assessment targets symfony/messenger ^8.1 (latest patch v8.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 the 5.4 LTS branch covering envelope/stamp deserialization (rejecting decoded messages that carry non-sendable stamps, validating stamps on unserialized envelopes, and restricting what X-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.54 if you're still on LTS, latest patch of ^8.1 otherwise. 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).

  1. 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
    
  2. 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',
    ];
    
  3. 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) {}
    }
    
  4. 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));
        }
    }
    
  5. 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)]);
    }
    
  6. 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
    
  7. Create the Doctrine schema if you use the Doctrine transport:

    php artisan messenger:setup-transports
    

First Use Case: Background Jobs

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


Implementation Patterns

Core Workflows

  1. Dispatching

    • MessageBusInterface::dispatch(object $message, array $stamps = []) returns an Envelope — read results from stamps on it.
    • Stamps are the extension mechanism: DelayStamp (ms), BusNameStamp, TransportNamesStamp (route a single message to a different transport), RedeliveryStamp (retries).
    • Route per-message instead of per-class when you need to override config:
      $bus->dispatch($message, [new TransportNamesStamp(['async_priority'])]);
      
  2. Handling

    • #[AsMessageHandler] with fromTransport, bus, priority, handles arguments.
    • Multiple handlers per message run in priority order on the same bus.
    • Handlers are resolved through the container — constructor-inject your services normally.
  3. Transports

    • Doctrine — durable, transactional with your DB, easiest to inspect:
      'doctrine' => ['dsn' => 'doctrine://default?queue_name=messenger&table_name=messenger_messages'],
      
    • Redis — low latency; set read_write_timeout and use a dedicated Redis DB to avoid eviction/contention with cache.
    • SQS/AMQP — when you need at-least-once delivery with external ops tooling.
  4. 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'],
    ],
    
    • Throw RecoverableMessageHandlingException to force a retry regardless of strategy.
    • Throw anything else to consume a retry; throw UnrecoverableMessageHandlingException to send straight to the failure transport.
    • Inspect/replay with messenger:failed:show, messenger:failed:retry, messenger:failed:remove.
  5. Middleware

    • Signature is 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);
        }
    }
    
    • Register on the bus before HandleMessageMiddleware for dispatch-side effects, after it for post-handle effects.
    • Custom stamps you attach must survive a serializer round-trip. That has always been good practice; as of the security release it's also enforced at decode time (see Gotchas §1).
  6. Batch processing

    • Use Symfony\Component\Messenger\Handler\BatchHandlerTrait with shouldFlush() / flush(); flush also happens on worker shutdown.
    • There is no "batch transport" — batching is a handler concern, driven by --batch-style flushing, not routing config.

Integration with Laravel

  • Bind the bus in a service provider. Laravel's container has no Messenger autowiring, so build the bus explicitly:
    $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
    ]));
    
  • Handler locator: bridge Messenger's HandlersLocator to Laravel by scanning app/Handlers (or use AttributeLoader over a PSR-4 map).
  • Artisan commands: wrap 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.
  • Testing: point 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());
    
  • Interop: don't run Messenger and Laravel's queue for the same workload — pick one owner per job type to avoid double-processing during migration.

Gotchas and Tips

Common Pitfalls

  1. 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:

    • Any custom stamp you defined and attached in middleware must be serializable by your configured serializer and safe to rehydrate. Previously an unserializable or hostile stamp could ride along inside a payload; now the message is rejected during decoding. If your CorrelationStamp/TenantStamp currently holds a closure, a resource, an Eloquent model, or an unserializable value, upgrade day is when you find out.
    • Keep custom stamps to scalars/strings/IDs and implement Symfony\Component\Messenger\Stamp\StampInterface (a non-sendable stamp is a bug, not a shortcut).
    • Roll out by upgrading one worker first, watching for decode rejections, then the dispatch side.
  2. 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:

    • Any code that builds or mutates envelopes from raw header arrays (custom transports, a bespoke SQS/AMQP bridge, replay tooling that edits the stored payload, fixtures with hand-written JSON) is now outside the allowed surface. Go through Serializer::decode()/TransportMessageDecodingException semantics instead of constructing envelopes from arrays by hand.
    • If you run a shared or multi-tenant transport, this is the fix that stops a payload from injecting arbitrary stamps into your envelope. Upgrade is not optional.
  3. 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:

    • Good: deserialising an untrusted/foreign payload can no longer trigger autoload side effects — safer if your transport is shared.
    • Watch out: type resolution now happens without autoloading. If you deploy a change that renames, moves, or removes a message class while messages are still queued, decoding can fail with a decoding error instead of silently resolving the class. Keep message classes stable across deploys, drain queues before renames, or use a custom serializer that maps a stable string type to your class (and keep the old class as an alias for one release).
    • If you relied on getMessageType() returning a class loaded as a side effect, load it explicitly.
  4. 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.

  5. DelayStamp is milliseconds. new DelayStamp(3600) = 3.6 seconds. Use 3_600_000 for an hour. There is no $message->setDelay() — stamps only.

  6. Failure transport config is top-level, not per-transport: 'failure_transport' => 'failed'. Setting it inside a transport array silently does nothing.

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

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

  9. Serialization

    • Avoid closures, resources, and heavy Eloquent models in messages — pass IDs and re-query in the handler. Serialized models go stale between dispatch and handle.
    • For cross-language or version-tolerant payloads use Serializer (Symfony's symfony/serializer) instead of PhpSerializer.
    • Custom serializer implements SerializerInterface::decode(array $encoded): Envelope / encode(Envelope $envelope): array — note it's Envelope in and out, not object.
    • Because 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.
  10. 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.

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

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

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

Debugging Tips

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

  2. Inspect queues directly

    • Doctrine: 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: 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).
  3. 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".

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

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

Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
andydefer/laravel-notification
adachsoft/cli-process-tester
jhaskell13/laraflow
sebastienheyd/boilerplate-media-manager
ccast/tagixo-primix
pretendtrue/laravel-builder
sawmainek/laravel-scout-tntsearch-driver
tomshaw/laravel-calendar-table
dibakar/laravel-ownership
steadrun/laravel-monitor
labyrinthe/connect-laravel
elmogy/layan
drewdan/sentinel-client
dskripchenko/laravel-llm
fatryst/nuonuo
stevenay/myanfont
gmostafa/php-graphql-client
xeviant/paystack
snowsoft/faker
meditationsteps/blogetc