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

Behat Messenger Context Laravel Package

macpaw/behat-messenger-context

Behat context for Symfony Messenger that lets you assert messages were dispatched, handled, or routed to specific transports during your scenarios. Integrates with Behat's step definitions to give readable, behavior-driven coverage of async messaging flows in PHP applications.

View on GitHub
Deep Wiki
Context7

Getting Started

macpaw/behat-messenger-context is a small Behat extension that wires Symfony Messenger into your acceptance tests, so you can assert what was dispatched, where it went, and what was actually handled without hand-rolling a bus decorator for every suite.

Minimal setup

composer require --dev macpaw/behat-messenger-context

Register the context in behat.yml (it expects the Symfony kernel to be booted by an existing context such as behat/symfony2-extension):

default:
    suites:
        default:
            contexts:
                - App\Behat\FeatureContext
                - MacPaw\BehatMessengerContext\Context\MessengerContext

Then make sure your config/packages/test/messenger.yaml uses transports that don't leak out of the process — in-memory:// for anything you want to inspect, sync:// only when you deliberately want the handler to run inline:

framework:
    messenger:
        transports:
            async: 'in-memory://'

v3.1.0 in one line: the context now resets message queues before each scenario (PR #41), plus a documentation refresh (PR #40). That closes the single biggest gap for new adopters — you no longer need to wire a kernel reboot just to stop messages bleeding between scenarios. If you were previously relying on rebootKernel() in a @BeforeScenario hook purely for transport hygiene, try removing it and see what else depends on it (fixtures usually do — see Gotchas).

Where to look first

  1. The package README — it was rewritten in v3.1.0, so re-read it even if you evaluated an earlier version. It is a thin package and the step list is the whole contract.
  2. src/Context/ to see how the context resolves the bus/transport from the container and where the new queue-reset hook lives; that tells you what config keys you can override.
  3. Your own messenger.yaml for the test env, because 90% of "why didn't the step fire" is a transport configured as sync:// or a routing rule that doesn't match.

First use case — assert a controller dispatched a message instead of doing the work inline:

Scenario: Registering a user queues a welcome email
    Given I am on the registration page
    When I register as "[email protected]"
    Then a "App\Message\SendWelcomeEmail" message should have been sent to "async"

That single assertion is usually enough to justify the dependency: it decouples "the endpoint responded 201" from "the side effect was queued correctly", which is exactly the seam acceptance tests struggle with in Messenger-based apps.

Implementation Patterns

Assert-on-dispatch, not on-side-effect. Keep the async boundary in your feature files. One scenario proves the message is queued; a separate, lower-level test (or a messenger:consume --limit=1 invocation) proves the handler works. Trying to do both in one Behat scenario means you're asserting on emails/database rows that live behind a worker, which turns flaky fast.

Intercept then consume. The most valuable workflow is: intercept the transport so nothing is handled, drive the UI, assert the message shape, then explicitly consume from the transport inside the same scenario and assert the outcome. This gives you a deterministic "queue → worker" flow without a running messenger:consume process.

Let v3.1.0 handle queue isolation — then audit what's left. Because the context now clears queues at the start of every scenario, the classic @BeforeScenario reboot hook becomes optional for transport state. Keep it only if you still need a fresh container (e.g. you're mutating service definitions, swapping adapters per scenario, or testing container compilation). For most suites, dropping the hook is a genuine speed win: kernel reboots are the slowest thing in a Behat run.

Use a dedicated test bus for assertions. If your app has multiple buses (command.bus, query.bus, event.bus), point the context at the bus under test rather than trying to assert across all of them. A common layout:

framework:
    messenger:
        buses:
            command.bus: ~
            event.bus:
                default_middleware: allow_no_handlers

Then write event-bus assertions in a separate suite or tag them so the context doesn't need to juggle two configurations.

Pair with behat/symfony2-extension kernel caching. The Symfony extension boots the kernel once per suite and reboots between scenarios only if you ask it to. With v3.1.0 you can leave kernel caching on and still get clean queues — that's the intended combination. Verify once with a two-scenario smoke test that dispatches the same message class in both and asserts exactly one occurrence each time.

Wrap scenarios in transactions. If the handler under test writes to Doctrine, combine the Messenger steps with behat/doctrine-context's transaction rollback so consuming a message doesn't leave rows behind for the next scenario. The new queue reset does nothing for the database.

Custom steps for your domain. The generic "a message of class X was sent" step is fine for smoke coverage, but the high-value steps are domain-shaped ones built on top of the package's assertions:

/**
 * @Then a welcome email should be queued for :email
 */
public function aWelcomeEmailShouldBeQueuedFor(string $email): void
{
    $messages = $this->getTransportMessages('async');

    Assert::assertNotEmpty(array_filter(
        $messages,
        fn ($m) => $m instanceof SendWelcomeEmail && $m->getRecipient() === $email
    ));
}

That keeps the Gherkin readable for product folks while the package does the container plumbing.

CI integration. Because the package runs entirely in-process against in-memory:// transports, no RabbitMQ/Redis service is needed in CI for the Behat suite. Keep a separate integration job (with real transports) if you want end-to-end coverage — but don't make the acceptance suite depend on broker availability.

Gotchas and Tips

sync:// transports bypass everything. If async is routed to sync:// in the test env, the message is handled inline and never appears in any transport. Assertions silently return empty rather than failing loudly. Double-check framework.messenger.routing in config/packages/test/.

Routing beats transport names. A step asking for "async" will find nothing if your routing block sends that message class to a different transport alias. Assert against the alias the routing actually resolves to, or use the package's bus-level assertions instead.

Interception must happen before the action. Steps like "intercept transport X" configure a middleware/transport decorator at container level, so setting it mid-scenario after a dispatch has no effect on messages already queued. Put the Given first, always.

The v3.1.0 reset is per-scenario, not per-step. Clearing happens at scenario start. Within a single scenario, messages accumulate exactly as before — if you dispatch the same message twice and assert "one message was sent", the step semantics still matter. Assert counts or filter by payload, don't assume the queue is empty after a When.

Queue reset ≠ fixture reset. The new hook clears transports; it does not clear your database. If you previously used rebootKernel() as a blunt instrument that happened to reset fixtures too, removing it in favour of v3.1.0's reset will surface leaked rows. Use behat/doctrine-context transactions (or explicit fixture teardown) and treat the two concerns separately.

Upgrading from v3.0.x. This is a minor bump with no documented breaking changes, but the reset hook changes ordering: any of your own @BeforeScenario hooks that dispatched a message as setup may now run before or after the reset depending on registration order. If you seed messages in a hook, run a --dry-run of the suite and confirm expectations still hold. Pin ^3.1 and read the diff on src/Context/ before bumping further.

Serialization differences between in-memory and real transports. In-memory transports pass the message object by reference; AMQP/Redis serialize it. A message with a non-serializable property (a closure, a Doctrine entity with lazy proxies, a DateTimeImmutable in a custom normalizer) will pass Behat and explode in production. If you care, add one scenario that runs through messenger.transport.symfony_serializer explicitly.

Handlers are not run by assertions. The package asserts on dispatch. If a scenario needs the handler's outcome, consume explicitly — and remember that consumption runs in the same PHP process, so a handler that expects a long-running worker (retries, --time-limit) behaves differently than in prod.

Multiple buses, one context instance. If the package is registered once but your app has three buses, verify which bus the context resolves by default. In small apps it's messenger.default_bus; in apps with explicit bus config it may be ambiguous. Override via the context constructor arguments if supported, or write a tiny subclass that pins the bus.

Extension points worth knowing. The context is intentionally thin — the real extension points are (a) subclassing the context to add domain steps, (b) registering a custom message factory if your messages aren't plain DTOs, and (c) decorating the bus with your own middleware for cross-cutting assertions (e.g. "no message older than X was dispatched"). Prefer (a); the package is small enough that a subclass is cheaper than fighting its assumptions. Note that subclassing now means inheriting the reset hook too — if you override its lifecycle methods, call the parent.

Still a small package — pin it. The project is young and lightly depended on. Pin ^3.1 in composer.json, read release notes before minor bumps (v3.1.0 itself changed lifecycle behaviour), and consider vendoring the context class into tests/Behat/ if you need to patch it. It's ~a few hundred lines; forking is a realistic fallback if upstream stalls.

Debugging tip. When a step fails with "no messages found", the fastest diagnosis is to dump the transport contents in an @AfterScenario hook rather than re-reading the step definition — it tells you instantly whether the problem is dispatch, routing, or interception. With automatic resets in place, remember that the @AfterScenario dump shows only the current scenario's messages, which is exactly what you want.

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.
coleus/frame
thtg88/journalism
filagora/filagora-ai-forums
rlacerda83/query-parser
socialdept/atp-orm
cboxdk/laravel-assistant-client
socialdept/atp-resolver
socialdept/tenant-domains
tautvydasr/blade-class-names
socialdept/color-scales
maherelgamil/controller
coleus/widgets
coleus/table
coleus/users
coleus/music
coleus/health
coleus/calendar
coleus/notes
coleus/support
coleus/settings