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 Test Laravel Package

zenstruck/messenger-test

TestTransport for symfony/messenger that intercepts queued messages so you can inspect and assert against them in your tests. Sent messages are serialized and unserialized as a check, and the transport can also process queued messages. Use the InteractsWithMessenger trait to assert queue state, mess...

View on GitHub
Deep Wiki
Context7

Getting Started

Install the package as a dev dependency:

composer require --dev zenstruck/messenger-test

Symfony Flex usually registers Zenstruck\Messenger\Test\ZenstruckMessengerTestBundle under test: true in config/bundles.php automatically. If it doesn't, add it manually:

// config/bundles.php
Zenstruck\Messenger\Test\ZenstruckMessengerTestBundle::class => ['test' => true],

Then point every transport you want to inspect at the test:// DSN only in the test environment:

# config/packages/messenger.yaml
when@test:
    framework:
        messenger:
            transports:
                async: test://

Your first test is usually a "did this controller/service dispatch the right message?" assertion:

use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use Zenstruck\Messenger\Test\InteractsWithMessenger;

class RegisterUserTest extends KernelTestCase
{
    use InteractsWithMessenger;

    public function test_it_queues_a_welcome_email(): void
    {
        // ...run the code that dispatches the message

        $this->transport()->queue()->assertCount(1);
        $this->transport()->queue()->assertContains(SendWelcomeEmail::class);
    }
}

The three classes worth reading first are InteractsWithMessenger (test entry point), Transport\TestTransport (the DSN transport and processing controls), and EnvelopeCollection/TestEnvelope (the assertion API you'll use in almost every test).

Implementation Patterns

Assert on the queue, not on the bus

The default behaviour is interception: messages routed to a test:// transport are serialized/unserialized and parked in an in-memory queue rather than being handled. That means your first layer of tests can be pure "dispatch contract" tests:

$this->transport()->queue()->assertEmpty();
$this->transport()->queue()->assertCount(3);
$this->transport()->queue()->assertContains(MyMessage::class);
$this->transport()->queue()->assertContains(MyMessage::class, 3);
$this->transport()->queue()->assertNotContains(MyMessage::class);

Because messages are round-tripped through the serializer, this also acts as a cheap serializability test: a message with a non-serializable property (a closure, a resource, an entity graph) will fail here, before it fails in production.

If you only care whether something was dispatched and not about the transport boundary, the README notes you can assert on the bus itself instead; use the transport when routing/serialization is part of the behaviour you're testing.

Drive the full lifecycle: queue → process → acknowledged/rejected

This is where the package earns its keep versus a simple Queue::fake()-style swap. You can advance the queue and assert on outcomes:

$this->transport()->queue()->assertCount(3);

$this->transport()->process(1);
$this->transport()->queue()->assertCount(2);

$this->transport()->process(); // drain everything (including messages created while handling)
$this->transport()->queue()->assertEmpty();
$this->transport()->acknowledged()->assertCount(3);
$this->transport()->rejected()->assertEmpty();

Use processOrFail() when an empty queue should be a test failure rather than a no-op — useful for guarding against "the message was never actually queued" false positives.

Treat the four collections as one API

queue(), dispatched(), acknowledged(), and rejected() all return an EnvelopeCollection, so assertions you learn once apply everywhere. Filtering helpers are the most useful day-to-day:

$messages = $this->transport()->queue()->messages(MyMessage::class); // MyMessage[]

$envelope = $this->transport()->queue()->first(MyMessage::class);
$envelope->assertHasStamp(DelayStamp::class);

// Callback filtering, with the message class as the typehint:
$this->transport()
    ->acknowledged()
    ->first(fn(MyMessage $m): bool => $m->isPriority())
    ->assertHasStamp(SomeStamp::class);

first() returns a TestEnvelope, an Envelope decorator — all normal Envelope methods (getMessage(), last(), all()) keep working, plus the stamp assertions.

Use the fluent chain for multi-assertion scenarios

Instead of repeating $this->transport() and risking brittle variable juggling, chain and use back():

$this->transport()
    ->queue()
        ->assertNotEmpty()
        ->assertContains(MyMessage::class)
    ->back()
    ->dispatched()
        ->assertEmpty()
    ->back()
    ->acknowledged()
        ->assertEmpty();

This reads well in code review and mirrors the "assert the state at each step" style Symfony developers expect from WebTestCase crawlers.

Seed the transport directly for handler-focused tests

You don't need the full HTTP/bus flow to test a worker path — put a message on the transport yourself:

$this->transport()->send(new MyMessage('payload'));
$this->transport()->send(Envelope::wrap(new MyMessage(), [new SomeStamp()]));
$this->transport()->process();
$this->transport()->acknowledged()->assertContains(MyMessage::class);

This is the closest analogue to "arrange a job in the queue, then run the worker" and is often faster and more focused than dispatching through a service just to reach the queue.

Unblock mode for end-to-end tests

When interception gets in the way — e.g. you want a WebTestCase request to fully complete including handlers — switch off the block:

$this->transport()->unblock();

// ...make the request; messages are handled immediately

$this->transport()->intercept();

Calling unblock() when messages are already queued processes them first. You can make this the default per transport:

async: test://?intercept=false

Multiple transports and environment defaults

Give each transport its own test:// DSN and address them by name:

$this->transport('high')->queue()->assertContains(HighPriorityMessage::class);
$this->transport('low')->acknowledged()->assertEmpty();

Configuration knobs live in the DSN query string, which is convenient because they can be overridden per test environment:

  • test://?catch_exceptions=false — let handler exceptions bubble instead of being captured as rejections.
  • test://?intercept=false — process messages immediately as they're sent.
  • test://?test_serialization=false — skip the serialize/unserialize round-trip for speed.
  • test://?support_delay_stamp=true — respect DelayStamp (requires a PSR-20 clock; Symfony's symfony/clock is auto-wired).

DelayStamp testing with a mock clock

If you opt into delay support, pair it with ClockSensitiveTrait so you can simulate the passage of time without sleeping:

$clock = self::mockTime();

$transport = $this->transport('async');
$transport->process();
$transport->queue()->assertCount(2);           // both messages are still delayed
$transport->acknowledged()->assertCount(0);

$clock->sleep(60 * 60 * 24);                   // advance one day
$transport->process()->acknowledged()->assertContains(TakeSomeAction1::class);
$transport->queue()->assertCount(1);

Isolate tests with resets

The transport accumulates state. Reset in setUp() to avoid order-dependent tests, or use TestTransport::resetAll() when a test exercises several transports:

protected function setUp(): void
{
    self::bootKernel();
    TestTransport::resetAll();
}

Gotchas and Tips

  • The when@test block is not optional. If you forget it, the transport isn't a test:// DSN and none of these helpers see anything. A "queue is always empty" failure is almost always a YAML environment problem, not a code problem.
  • Messages only reach the transport if routing sends them there. A message handled synchronously on the bus (or with no routing rule) never appears in queue(). When debugging an empty queue, check messenger.yaml routing before re-reading your assertions. This is the main conceptual difference from Laravel's Queue::fake(), which swaps the implementation wholesale; here the real Messenger pipeline — routing, stamps, serialization — still runs.
  • Failed handlers don't fail your test by default. The transport catches exceptions and puts the message in rejected(). That's great for asserting failure paths, terrible for debugging a broken handler — you'll see a green test with a rejection you didn't assert on. Call $this->transport()->throwExceptions() (or set catch_exceptions=false in the DSN) while investigating.
  • process() is not just "run one message". It also processes messages created during handling, up to the optional $number. This is usually what you want for cascade scenarios, but don't assume process(1) leaves exactly one item consumed if your handler dispatches more work. Assert on queue()->count() to confirm actual behaviour.
  • intercept() vs unblock() changes the meaning of queue(). In unblock mode the queue is drained as you go, so queue assertions become meaningless. Pick one style per test.
  • Delayed messages and unblock mode don't mix. A delayed message can't be handled by the unblocking mechanism; you must sleep() on the clock and then call process() explicitly.
  • DelayStamp support is off by default. Stamps are preserved on the envelope (so assertHasStamp(DelayStamp::class) works), but timing isn't honoured unless you enable support_delay_stamp. A missing PSR-20 clock implementation will surface as a container error rather than a test assertion failure.
  • first() needs a non-empty collection. Guard with assertNotEmpty() or assertCount() first if the collection might legitimately be empty; otherwise you get a "no envelope found" failure that masks the real cause.
  • Assertion-count semantics: assertContains(MyMessage::class, 0) is valid and equivalent to assertNotContains(), which is handy in data providers but easy to misread in review. Prefer the explicit form.
  • TestEnvelope is a decorator, not a subclass you should type-hint against in application code. Keep production code on Envelope; the test helpers stay in tests.
  • Reset between tests if the kernel is reused. Depending on your test bootstrap, transports can leak state across test methods. TestTransport::resetAll() in setUp() is cheap insurance.
  • Serialization checking is a feature, not overhead. It catches the classic "works in dev, fails in the worker" bug where a message holds an entity or closure. Don't disable test_serialization globally just to make one awkward message pass — fix the message.
  • Extension points: custom behaviour hangs off the DSN options and the TestTransport/EnvelopeCollection/TestEnvelope trio. If you need repeated assertion helpers (e.g. "assert these three messages were queued in this order"), wrap EnvelopeCollection in your own assertion trait rather than scattering raw messages() calls through your suite.
  • Version note: DelayStamp support landed in 1.8.0. If delay-related options appear to do nothing, check the installed version before assuming a config error.
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.
jinas/dhivatar
store-doc/laravel-storage-driver
plus-it/laravel-translation-manager
diephp/perhaps
diephp/telegraph
osmaviation/spreadsheet
microsoft/fluentui-system-icons
mmccook/php-json-canonicalization-scheme
middlewares/firewall
qubiqx/montapacking
nyoncode/laravel-package-console
pnscripts/filament-broadquill-lite
pnscripts/laravel-sentfold-lite
vleap/mx-sdk-laravel-internal
twidpay/utils
htetmyathlaing/scout-elasticsearch-driver
artisan-toolbox/front-awesome
ls/client-assistant
users-au/laravel-client
cgv/localized-resource