Thanks to visit codestin.com
Credit goes to github.com

Skip to content

[Messenger] Let a flow carry its context in stamps: propagation, handler arguments and identity - #65899

Open
nicolas-grekas wants to merge 1 commit into
symfony:8.2from
nicolas-grekas:messenger-propagated-stamps
Open

[Messenger] Let a flow carry its context in stamps: propagation, handler arguments and identity#65899
nicolas-grekas wants to merge 1 commit into
symfony:8.2from
nicolas-grekas:messenger-propagated-stamps

Conversation

@nicolas-grekas

@nicolas-grekas nicolas-grekas commented Sep 8, 2026

Copy link
Copy Markdown
Member
Q A
Branch? 8.2
Bug fix? no
New feature? yes
Deprecations? no
Issues -
License MIT

This is a proposal, open for discussion. It started from Dariusz Gafka's article Symfony Messenger vs Ecotone: The Real Difference, which describes how Ecotone approaches this. What is proposed here is a free interpretation for Symfony, built on Messenger's own model rather than ported from Ecotone, so it departs from the article where the two models differ.

Stamps are the place for what a flow knows about a message but the message itself is not: a trace id, the tenant, who asked, which import run this row belongs to. Two things stop them from being used that way today:

  1. a handler that dispatches a follow-up message starts from an empty envelope, so a stamp attached at the entry point is gone at the next hop;
  2. a handler receives the message only, so it cannot read a stamp at all without a custom middleware feeding HandlerArgumentsStamp.

Either gap alone forces the same workaround, and it is the interesting part of this PR: the value gets added as a field to the message class, then to the next message class, and so on down the flow. Messages end up carrying data they do not use, only to pass it along. This PR closes both gaps, so a value can be attached once and read where it is needed.

The two halves together

final class TenantStamp implements PropagatedStampInterface
{
    public function __construct(public readonly string $tenantId) {}
}

// Entry point: attached once.
$bus->dispatch(new ImportCatalog($path), [new TenantStamp($tenant->id)]);

// First handler: splits the file. It knows nothing about tenants and does not have to.
#[AsMessageHandler]
final class ImportCatalogHandler
{
    public function __construct(private MessageBusInterface $bus) {}

    public function __invoke(ImportCatalog $import): void
    {
        foreach ($this->reader->rows($import->path) as $row) {
            $this->bus->dispatch(new ImportRow($row)); // the TenantStamp is copied onto it
        }
    }
}

// Second handler: reads the tenant, although ImportRow never carried it.
#[AsMessageHandler]
final class ImportRowHandler
{
    public function __invoke(ImportRow $row, TenantStamp $tenant): void
    {
        $this->catalogs->for($tenant->tenantId)->add($row);
    }
}

Without the first half, ImportCatalogHandler has to re-attach the stamp on every dispatch, and forgetting once loses it silently. Without the second half, ImportRow has to carry a $tenantId field, and so does every other message of the flow, which is what the stamp was meant to avoid.

Propagated stamps

A stamp implementing the new PropagatedStampInterface is copied onto every message dispatched while the message carrying it is being handled, at any nesting depth and across buses. The copy is done by the new PropagateStampsMiddleware, which keeps a stack of the envelopes being handled. Rules:

  • an explicit stamp of the same class on the nested message wins, nothing is copied for that class;
  • all stamps of a propagated class are copied, in order;
  • a received message (consumed by a worker) is never modified, but what its handler dispatches inherits its propagated stamps, so a stamp must be sendable for the propagation to continue past a transport;
  • messages dispatched with DispatchAfterCurrentBusStamp inherit at dispatch time; what their own handlers dispatch later inherits from the root message being handled.

CorrelationStamp is what the component ships on top of the interface. Give it the identifier of the request or of the process the flow belongs to, and every message of the flow carries it:

$bus->dispatch(new ImportCatalog($path), [new CorrelationStamp($request->headers->get('X-Request-Id'))]);

Nothing attaches it on its own, so an application that does not use it sees no change. The identity middleware below adds one when it is enabled.

FrameworkBundle enables the middleware by default as propagate_stamps, right after add_bus_name_stamp_middleware and before dispatch_after_current_bus (that order is required, since queued messages are executed once the stack has unwound). It is one shared service for all buses, so that a command handler dispatching an event propagates to the event bus. Buses configured with default_middleware: false have to add it themselves.

Stamp and envelope arguments for handlers

A handler method can declare, after the message, parameters typed with Envelope or with a stamp class. This half is also useful on its own, for stamps the system puts on the envelope:

#[AsMessageHandler]
final class SendInvoiceHandler
{
    public function __invoke(SendInvoice $invoice, ?RedeliveryStamp $redelivery = null): void
    {
        if (2 < $redelivery?->getRetryCount()) {
            // last attempts, fall back to the plain text invoice
        }
    }
}

A missing stamp gives null to a nullable parameter, keeps the default value of a parameter that has one, and throws a LogicException for a required one:

Handler "App\Import\ImportRowHandler" requires a "App\Import\TenantStamp" stamp for argument "$tenant", but the envelope carries none.

Message identity

Correlation says which flow a message belongs to. Two more stamps say which message this is and what caused it:

  • MessageIdStamp identifies the message itself, where TransportMessageIdStamp identifies one delivery in one transport. It is kept across a retry, the failure transport and a replay, so it stays the same identifier from one end of a flow to the other.
  • CausationStamp holds the id of the message whose handling dispatched this one.

AddIdentityStampsMiddleware assigns them. A message that opens a flow gets an id and a correlation built from it. A message dispatched while another one is handled gets its own id and the id of that one as its cause. Anything the envelope already carries is left untouched, which is what lets an incoming message keep its place in the flow. It keeps its own frame of the messages in flight, so causation needs no interface of its own, and it has to run before propagate_stamps: the frame it pushes is what the children read, and the correlation of a nested dispatch has to come from the parent.

Stamping every dispatch changes serialized envelopes, so it is opt-in:

framework:
    messenger:
        identity_stamps: true

Identifiers are UUIDv7 when the Uid component is installed, which keeps them ordered by creation and friendly to a database index, and 32 random hexadecimal characters otherwise. The generator is a closure, so decorating messenger.message_id_generator replaces it.

The option is global rather than per bus on purpose, because the middleware has to see every hop. A bus that skips it leaves its messages without an id, and the messages their handlers dispatch then take their cause from the nearest ancestor that did run, which is not the message that dispatched them. Enabling it on a command bus but not on the event bus it dispatches to gives this:

message bus id caused by
PlaceOrder command, on 875c608c none
OrderWasPlaced event, off none none
NotifyWarehouse command, on b2303dd1 875c608c

NotifyWarehouse was dispatched by the handler of OrderWasPlaced, yet it names PlaceOrder as its cause. So a bus configured with default_middleware: false has to list add_identity_stamps itself, immediately before propagate_stamps, or stay out of flows that are identified elsewhere.

The stamp is looked up by exact class (Envelope::last()), so a parameter typed with an interface or a parent class never matches. Stamp parameters are passed as named arguments, while the extra arguments of HandlerArgumentsStamp stay positional, so the two must not overlap. Batch handlers keep their Acknowledger parameter.

Public API

  • Symfony\Component\Messenger\Stamp\PropagatedStampInterface (marker interface)
  • Symfony\Component\Messenger\Middleware\PropagateStampsMiddleware
  • Symfony\Component\Messenger\Stamp\CorrelationStamp, the stamp of the component using that interface
  • Symfony\Component\Messenger\Stamp\MessageIdStamp and Symfony\Component\Messenger\Stamp\CausationStamp
  • Symfony\Component\Messenger\Middleware\AddIdentityStampsMiddleware, and framework.messenger.identity_stamps to enable it
  • Handler methods may declare Envelope or stamp-typed parameters after the message. HandlerDescriptor::getStampParameters() carries the reflection result and is @internal (the class is final).

The two halves are independently mergeable and touch disjoint files. They are one PR because either one alone leaves the workaround above in place. Identity is a third part, built on the first: it is the flow context the framework itself puts on a message. Say the word and I will split them.

Checks

  • ./phpunit src/Symfony/Component/Messenger/Tests and ./phpunit src/Symfony/Bundle/FrameworkBundle/Tests: green (usual missing-server skips).
  • The three snippets above were run against this branch, including the exception message.
  • Revert-verified: the middleware tests fail without the middleware and the interface; the handler-argument tests fail with ArgumentCountError on the base branch; the bundle tests fail without the wiring.
  • The bundle wiring is guarded with class_exists() for the low-deps job (Messenger 7.4/8.0/8.1), and the identity middleware falls back to its own generator when the Uid component is absent.
  • The identity tests cover a flow two levels deep: one correlation shared by the three messages, three distinct ids, no cause on the root, and a causation chain that follows the dispatch tree.

Documentation

  • Stamps as flow context: the marker interface, the copy rules, the received-message rule, and the worked example above.
  • CorrelationStamp: what to put in it, and that it follows a flow through transports and retries as long as it is attached at the entry point.
  • Message identity: the identity_stamps option and why it is opt-in, the three stamps and how they relate, MessageIdStamp against TransportMessageIdStamp, the UUIDv7 generator and how to replace it, and why a bus that lists its middleware by hand has to include add_identity_stamps immediately before propagate_stamps, with the broken causation chain above as the reason.
  • The default middleware position and the shared-instance requirement for custom middleware lists.
  • Stamp and envelope parameters: nullable, default, required, exact class match, no overlap with HandlerArgumentsStamp.

@carsonbot carsonbot added this to the 8.2 milestone Sep 8, 2026
@nicolas-grekas
nicolas-grekas force-pushed the messenger-propagated-stamps branch from 7981a1a to 4ac38df Compare September 8, 2026 14:34
@nicolas-grekas
nicolas-grekas force-pushed the messenger-propagated-stamps branch from 4ac38df to c8e1eb3 Compare September 8, 2026 15:00
@nicolas-grekas nicolas-grekas changed the title [Messenger] Propagate stamps to nested dispatches and inject stamps into handlers [FrameworkBundle][Messenger] Let a flow carry its context in stamps: propagation, handler arguments and identity Sep 8, 2026
@nicolas-grekas
nicolas-grekas force-pushed the messenger-propagated-stamps branch from c8e1eb3 to 036e6fc Compare September 8, 2026 15:13
@nicolas-grekas
nicolas-grekas force-pushed the messenger-propagated-stamps branch from 036e6fc to 77c7add Compare September 13, 2026 06:38
@nicolas-grekas nicolas-grekas changed the title [FrameworkBundle][Messenger] Let a flow carry its context in stamps: propagation, handler arguments and identity [Messenger] Let a flow carry its context in stamps: propagation, handler arguments and identity Sep 13, 2026
…nto handlers

Stamps implementing the new PropagatedStampInterface are copied by
PropagateStampsMiddleware onto every message dispatched while the message
carrying them is being handled. The middleware keeps a stack of the envelopes
being handled, so nested dispatches at any depth inherit the stamps of the
message on top of the stack, except for the stamp classes they already carry.
A received message is never enriched, but the messages its handler dispatches
inherit its propagated stamps. MessengerBundle registers the middleware as one
shared service, listed after add_bus_name_stamp_middleware and before
dispatch_after_current_bus, so that propagation works across buses and for
messages queued with DispatchAfterCurrentBusStamp.

Handler methods can now declare, after the message argument, arguments typed
with Envelope or with a stamp class. HandlerDescriptor records them and
HandleMessageMiddleware passes them as named arguments: the envelope, or the
last stamp of that class. A missing stamp gives null to a nullable argument,
leaves the default value of an optional argument in place, and throws a
LogicException otherwise. Extra arguments from HandlerArgumentsStamp and stamp
arguments must not overlap, since a named argument cannot replace a positional
one.

CorrelationStamp is the built-in stamp using that interface: give it the
identifier of the request or of the process a flow belongs to, and every
message dispatched while that one is handled carries it, across buses and
transports.

Three stamps carry the identity of a message: MessageIdStamp identifies the
message itself and stays with it across retries and replays, CausationStamp
holds the id of the message whose handling caused this one, and CorrelationStamp
ties a whole flow together. AddIdentityStampsMiddleware adds them, only when the
message does not carry them already, and mints ids with a closure that can be
replaced. It must run before PropagateStampsMiddleware, which is what carries
the correlation from a message to the ones dispatched while it is handled.
MessengerBundle wires it behind the new messenger.identity_stamps option,
with a UUIDv7 generator when the Uid component is installed.
@nicolas-grekas
nicolas-grekas force-pushed the messenger-propagated-stamps branch from 77c7add to e5ae412 Compare September 13, 2026 14:00
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants