Skip to content

Domain · Application · Adapter

Messaging (CQRS)

Coordinate commands, queries, and events through portable envelopes and ports, then choose synchronous or supported asynchronous adapters at the boundary.

Transport adapters own submission and delivery; the handler re-enters the portable synchronous command path.

Messaging gives a use case one vocabulary for a command that changes state, a query that returns state, and an event that reports a completed fact. Keep payload data and immutable envelopes in the Domain; let the Application define buses, handlers, filters, and dispatch ports; bind routing, pipelines, serialization, queue consumers, and framework configuration in Adapter code.

Ownership. Command, Query, Event, their message envelopes, MessageId, and Meta are Domain primitives. Command/query buses, handlers and filters, event-dispatcher contracts, and subscribers belong to the Application. Synchronous routers and pipelines, service-aware handlers, serializers, and framework queue bridges are adapters. Consumers own handler business logic, transport topology, retries, workers, dead-letter handling, and transaction/outbox policy.

Dependencies. The portable synchronous path requires PHP 8.5+ and this package. Symfony Messenger support is optional via symfony/messenger; Laravel asynchronous command and event jobs require laravel/framework; CodeIgniter queue support requires codeigniter4/queue; Symfony autowiring passes require symfony/dependency-injection.

Install.

composer require johnnickell/fight-common

Start with a portable synchronous command.

use Fight\Common\Adapter\Messaging\Command\Sync\Routing\InMemoryCommandRouter;
use Fight\Common\Adapter\Messaging\Command\Sync\RoutingCommandBus;
use Fight\Common\Domain\Messaging\Command\CommandMessage;

$router = new InMemoryCommandRouter();
$router->registerHandler(PlaceOrder::class, new PlaceOrderHandler($orders, $events));

$commands = new RoutingCommandBus($router);
$commands->execute(new PlaceOrder($orderId));

// A pre-built envelope preserves its MessageId, timestamp, and metadata.
$commands->dispatch(CommandMessage::create(new PlaceOrder($orderId)));

Synchronous command and query handlers propagate their failures to the caller. A synchronous event dispatcher invokes event-specific handlers and then AllEvents handlers, collecting completed handler failures in EventDispatchFailed. Queries have only the synchronous QueryBus path; this package does not provide an asynchronous query bus.

Async delivery

Only commands and events have supported asynchronous adapters. Symfony Messenger sends complete CommandMessage and EventMessage envelopes and consumes them through the framework-neutral handlers. Laravel queues QueuedCommandMessage and QueuedEventMessage after a Laravel database commit; it is not an atomic outbox. CodeIgniter Queue submits complete serialized envelopes to project-selected jobs and has no portable post-commit guarantee. In every case, queue/broker choice, retry and backoff, worker lifecycle, and failure storage are application policy.

An asynchronous event dispatcher intentionally has no local subscribers or handlers; delivery returns to a synchronous dispatcher in the consumer. Repeated delivery retains the message identity and full envelope, but no adapter promises exactly-once effects. Make externally visible work idempotent and use an application-owned outbox or event-store design when atomic persistence and publication are required.

Reference

A full CQRS architecture with commands, queries, and events. Message primitives live in Domain\Messaging, service contracts in Application\Messaging, and adapters (sync + async) in Adapter\Messaging. The Symfony Messenger bridge provides async transport, and compiler passes auto-wire handlers and filters from the DI container.

Domain\Messaging
├── Message (interface)
├── BaseMessage (abstract)
├── MessageId / MessageType (enum) / Payload / Meta
├── Command\Command / CommandMessage
├── Query\Query / QueryMessage
└── Event\Event / EventMessage / AllEvents / CommandFailedEvent

Application\Messaging
├── Command\CommandBus / SynchronousCommandBus / AsynchronousCommandBus
│            CommandHandler / CommandFilter
├── Query\QueryBus / QueryHandler / QueryFilter
└── Event\EventDispatcher / SynchronousEventDispatcher / AsynchronousEventDispatcher
                EventSubscriber

Adapter\Messaging
├── Command\Sync\RoutingCommandBus + CommandPipeline
│            Sync\Routing\{CommandRouter, InMemory*, ServiceAware*}
│   Symfony\MessengerCommandBus (legacy: Command\Async\MessengerCommandBus)
├── Query\RoutingQueryBus + QueryPipeline
│       Query\Routing\{QueryRouter, InMemory*, ServiceAware*}
└── Event\Sync\{SimpleEventDispatcher, ServiceAwareEventDispatcher}
    Symfony\{MessengerEventDispatcher, Serializer\SymfonyMessageSerializer}
    Handler\{CommandMessageHandler, EventMessageHandler}

Adapter\ServiceContainer\Symfony
├── CommandHandlerCompilerPass
├── CommandFilterCompilerPass
├── QueryHandlerCompilerPass
├── QueryFilterCompilerPass
├── EventSubscriberCompilerPass
└── TemplateHelperCompilerPass

Table of Contents

  1. Message Primitives
  2. Commands
  3. Queries
  4. Events
  5. Pipeline Filters
  6. Async with Symfony Messenger
  7. Compiler Passes
  8. Full Symfony Configuration
  9. Controller Examples

Message Primitives

Message Interface

Fight\Common\Domain\Messaging\Message

The root interface for all message envelopes. Extends Arrayable, Comparable, Equatable, JsonSerializable, and Serializable.

interface Message extends Arrayable, Comparable, Equatable, JsonSerializable, Serializable
{
    public function id(): MessageId;
    public function type(): MessageType;
    public function timestamp(): DateTimeImmutable;
    public function payload(): Payload;
    public function payloadType(): Type;
    public function meta(): Meta;
    public function withMeta(Meta $data): static;
    public function mergeMeta(Meta $data): static;
    public function toString(): string;
}

Equality and comparison are based on the MessageId — two messages with the same ID are considered equal regardless of other fields.

BaseMessage

Fight\Common\Domain\Messaging\BaseMessage

Abstract base implementing Message. Stores id, type, timestamp, payload, and meta. Serialization produces a uniform envelope:

[
    'id'           => '018abc...',     // MessageId as string
    'type'         => 'command',       // MessageType value
    'timestamp'    => '1712345678.123456', // Unix timestamp with microseconds
    'payload_type' => 'RegisterUserCommand',
    'payload'      => ['email' => '...', 'name' => '...'],
    'meta'         => ['trace_id' => 'abc123'],
]

MessageId

Fight\Common\Domain\Messaging\MessageId

Extends UniqueId — auto-generated UUID identifier for every message envelope.

$id = MessageId::generate();
$id = MessageId::fromString('018abc...');

MessageType

Fight\Common\Domain\Messaging\MessageType

A string-backed PHP enum:

enum MessageType: string
{
    case COMMAND = 'command';
    case QUERY   = 'query';
    case EVENT   = 'event';
}

Payload

Fight\Common\Domain\Messaging\Payload

Marker interface extended by Command, Query, and Event. Requires fromArray() / toArray() — the actual business data.

interface Payload extends Arrayable
{
    public static function fromArray(array $data): static;
    public function toArray(): array;
}

Meta

Fight\Common\Domain\Messaging\Meta

Key-value metadata container attached to every message envelope. Accepts scalars, arrays, or null; arrays may be nested and contain only arrays, scalars, or null. Guards against more complex types on set().

$meta = Meta::create(['trace_id' => 'abc', 'user_id' => 42]);

$meta->has('trace_id');     // true
$meta->get('trace_id');     // 'abc'
$meta->set('source', 'cli');
$meta->remove('user_id');
$meta->merge($otherMeta);
$meta->toArray();           // ['trace_id' => 'abc', 'source' => 'cli']
$meta->count();             // 2

Implements Countable, IteratorAggregate, JsonSerializable, Stringable.


Commands

Domain Layer

Command — marker interface extending Payload:

namespace Fight\Common\Domain\Messaging\Command;

interface Command extends Payload {}

CommandMessage — envelope wrapping a Command:

final class CommandMessage extends BaseMessage
{
    // Wrap a command in a message with auto-generated ID + timestamp
    public static function create(Command $command): static;

    // Deserialize from the envelope array (validates type === 'command')
    public static function arrayDeserialize(array $data): static;
}
$command  = new RegisterUserCommand('user@example.com', 'Alice');
$envelope = CommandMessage::create($command);

$envelope->id();              // MessageId
$envelope->type();            // MessageType::COMMAND
$envelope->payload();         // RegisterUserCommand
$envelope->meta();            // Meta (empty by default)
$envelope->withMeta($meta);   // clone with replacement meta
$envelope->mergeMeta($meta);  // clone with merged meta

Application Contracts

CommandBus — the bus interface. Two dispatch styles:

interface CommandBus
{
    // Wrap + dispatch (convenience)
    public function execute(Command $command): void;

    // Dispatch a pre-built message
    public function dispatch(CommandMessage $commandMessage): void;
}

SynchronousCommandBus / AsynchronousCommandBus — marker subinterfaces used by adapter consumers to declare intent.

CommandHandler — each handler declares which command it handles via a static method:

interface CommandHandler
{
    public static function commandRegistration(): string;
    public function handle(CommandMessage $commandMessage): void;
}
class RegisterUserHandler implements CommandHandler
{
    public static function commandRegistration(): string
    {
        return RegisterUserCommand::class;
    }

    public function handle(CommandMessage $commandMessage): void
    {
        /** @var RegisterUserCommand $command */
        $command = $commandMessage->payload();
        // ... business logic
    }
}

CommandFilter — middleware-style pipeline filter:

interface CommandFilter
{
    // $next signature: function (CommandMessage): void
    public function process(CommandMessage $commandMessage, callable $next): void;
}

Sync Adapters

CommandRouter — locates a handler for a command:

interface CommandRouter
{
    /** @throws LookupException when not found */
    public function match(Command $command): CommandHandler;
}

Two implementations:

Implementation Storage Resolution
InMemoryCommandRouter Direct handler instances registerHandler(CommandClass::class, $handlerInstance)
ServiceAwareCommandRouter Service IDs in container registerHandler(CommandClass::class, 'service_id') — lazy-loaded on match()
// InMemory — useful in tests
$router = new InMemoryCommandRouter();
$router->registerHandler(RegisterUserCommand::class, $handler);

// ServiceAware — production with DI
$router = new ServiceAwareCommandRouter($container);
$router->registerHandler(RegisterUserCommand::class, 'app.handler.register_user');

RoutingCommandBus — sync bus that delegates to the router:

final readonly class RoutingCommandBus implements SynchronousCommandBus
{
    public function execute(Command $command): void
    {
        $this->dispatch(CommandMessage::create($command));
    }

    public function dispatch(CommandMessage $commandMessage): void
    {
        $command = $commandMessage->payload();
        $this->commandRouter->match($command)->handle($commandMessage);
    }
}

CommandPipeline — decorates a SynchronousCommandBus with a stack of CommandFilters:

$pipeline = new CommandPipeline($routingCommandBus);
$pipeline->addFilter(new LoggingCommandFilter());
$pipeline->addFilter(new ValidationCommandFilter());

$pipeline->execute($command);  // goes through each filter, then the bus

Internally uses a LinkedStack of filters. Each filter calls $next to pass control to the next filter in the stack, ending at the inner bus.

Async Adapter

MessengerCommandBus — sends commands to a Symfony Messenger transport:

final readonly class MessengerCommandBus implements AsynchronousCommandBus
{
    public function execute(Command $command): void
    {
        $this->dispatch(CommandMessage::create($command));
    }

    public function dispatch(CommandMessage $commandMessage): void
    {
        $this->sender->send(new Envelope($commandMessage));
    }
}
// In a controller you use the async bus for commands
class RegisterController
{
    public function __construct(private AsynchronousCommandBus $commandBus) {}

    public function __invoke(Request $request): Response
    {
        $command = new RegisterUserCommand(
            $request->get('email'),
            $request->get('name')
        );

        $this->commandBus->execute($command);

        return new Response('Processing', 202);
    }
}

Example: RegisterUserCommand

use Fight\Common\Domain\Messaging\Command\Command;

final readonly class RegisterUserCommand implements Command
{
    public function __construct(
        private string $email,
        private string $name,
    ) {}

    public static function fromArray(array $data): static
    {
        return new static($data['email'], $data['name']);
    }

    public function toArray(): array
    {
        return ['email' => $this->email, 'name' => $this->name];
    }

    public function email(): string { return $this->email; }
    public function name(): string  { return $this->name; }
}

class RegisterUserHandler implements CommandHandler
{
    public function __construct(
        private UserRepository $users,
        private SynchronousEventDispatcher $events,
    ) {}

    public static function commandRegistration(): string
    {
        return RegisterUserCommand::class;
    }

    public function handle(CommandMessage $commandMessage): void
    {
        $command = $commandMessage->payload();
        $user = User::register($command->email(), $command->name());
        $this->users->save($user);

        $this->events->trigger(new UserRegisteredEvent($user->id()));
    }
}

Queries

Queries are always synchronous — there is no async query bus. The pattern mirrors commands.

Domain Layer

namespace Fight\Common\Domain\Messaging\Query;

interface Query extends Payload {}

QueryMessage — envelope wrapping a Query. Same structure as CommandMessage with type === 'query'.

$query    = new GetUserQuery('018abc...');
$envelope = QueryMessage::create($query);

Application Contracts

interface QueryBus
{
    public function fetch(Query $query): mixed;
    public function dispatch(QueryMessage $queryMessage): mixed;
}

interface QueryHandler
{
    public static function queryRegistration(): string;
    public function handle(QueryMessage $queryMessage): mixed;
}

interface QueryFilter
{
    public function process(QueryMessage $queryMessage, callable $next): void;
}

Adapters

QueryRouter (with InMemoryQueryRouter / ServiceAwareQueryRouter) — same pattern as commands.

RoutingQueryBus — sync-only bus:

final readonly class RoutingQueryBus implements QueryBus
{
    public function fetch(Query $query): mixed
    {
        return $this->dispatch(QueryMessage::create($query));
    }

    public function dispatch(QueryMessage $queryMessage): mixed
    {
        $query = $queryMessage->payload();
        return $this->queryRouter->match($query)->handle($queryMessage);
    }
}

QueryPipeline — decorates QueryBus with filter stack (same pipeline pattern as commands).

Example: GetUserQuery

final readonly class GetUserQuery implements Query
{
    public function __construct(private string $userId) {}

    public static function fromArray(array $data): static
    {
        return new static($data['user_id']);
    }

    public function toArray(): array
    {
        return ['user_id' => $this->userId];
    }

    public function userId(): string { return $this->userId; }
}

class GetUserHandler implements QueryHandler
{
    public function __construct(private UserRepository $users) {}

    public static function queryRegistration(): string
    {
        return GetUserQuery::class;
    }

    public function handle(QueryMessage $queryMessage): mixed
    {
        $query = $queryMessage->payload();
        return $this->users->find($query->userId());
    }
}

Events

Domain Layer

Event — marker interface extending Payload:

namespace Fight\Common\Domain\Messaging\Event;

interface Event extends Payload {}

EventMessage — envelope wrapping an Event. Same structure as CommandMessage/QueryMessage with type === 'event'.

$event    = new UserRegisteredEvent($userId);
$envelope = EventMessage::create($event);

AllEvents — marker class. Event subscribers can use this instead of a specific event class to register for every event.

final class AllEvents
{
    // No methods — marker only
}

CommandFailedEvent — a built-in event payload emitted when a command fails. Contains the original Command and error message:

final readonly class CommandFailedEvent implements Event
{
    public function __construct(
        private readonly Command $command,
        private readonly string $errorMessage,
    ) {}

    public function getCommand(): Command;
    public function getErrorMessage(): string;
}

Application Contracts

interface EventDispatcher
{
    // Wrap + dispatch
    public function trigger(Event $event): void;

    // Dispatch a pre-built message
    public function dispatch(EventMessage $eventMessage): void;

    // Subscriber management
    public function register(EventSubscriber $subscriber): void;
    public function unregister(EventSubscriber $subscriber): void;

    // Fine-grained handler control
    public function addHandler(string $eventType, callable $handler, int $priority = 0): void;
    public function getHandlers(?string $eventType = null): array;
    public function hasHandlers(?string $eventType = null): bool;
    public function removeHandler(string $eventType, callable $handler): void;
}

SynchronousEventDispatcher and AsynchronousEventDispatcher are marker subinterfaces.

EventSubscriber — declarative registration. The static method returns a map of event class → handler method, with optional priority:

interface EventSubscriber
{
    // Returns: [EventClass::class => 'methodName']
    // Or:     [EventClass::class => ['methodName', priority]]
    // Or:     [EventClass::class => [['methodOne', 10], ['methodTwo']]]
    // Use AllEvents::class to subscribe to everything
    public static function eventRegistration(): array;
}

Sync Adapters

SimpleEventDispatcher — the base implementation. Handlers are stored in-memory by event type, sorted by priority (highest first). dispatch() calls handlers for the specific event type, then handlers registered for AllEvents.

$dispatcher = new SimpleEventDispatcher();
$dispatcher->register($subscriber);
$dispatcher->addHandler(UserRegisteredEvent::class, $callable, 10);
$dispatcher->trigger(new UserRegisteredEvent($userId));

ServiceAwareEventDispatcher — extends SimpleEventDispatcher. Accepts service IDs instead of concrete instances. Lazy-loads handlers from the container on first dispatch:

$dispatcher = new ServiceAwareEventDispatcher($container);
$dispatcher->registerService(UserRegisteredEvent::class, 'app.subscriber.send_welcome_email');

// On dispatch, loads 'app.subscriber.send_welcome_email' from container
$dispatcher->trigger(new UserRegisteredEvent($userId));

Async Adapter

MessengerEventDispatcher — sends event messages to a Messenger transport. All register() / addHandler() / etc. are no-ops — the dispatcher only serializes and sends.

final readonly class MessengerEventDispatcher implements AsynchronousEventDispatcher
{
    public function trigger(Event $event): void
    {
        $this->sender->send(new Envelope(EventMessage::create($event)));
    }
}

Example: UserRegisteredEvent + Subscriber

final readonly class UserRegisteredEvent implements Event
{
    public function __construct(private string $userId) {}

    public static function fromArray(array $data): static
    {
        return new static($data['user_id']);
    }

    public function toArray(): array
    {
        return ['user_id' => $this->userId];
    }

    public function userId(): string { return $this->userId; }
}

class SendWelcomeEmailSubscriber implements EventSubscriber
{
    public function __construct(private Mailer $mailer) {}

    public static function eventRegistration(): array
    {
        return [UserRegisteredEvent::class => 'onUserRegistered'];
    }

    public function onUserRegistered(EventMessage $message): void
    {
        /** @var UserRegisteredEvent $event */
        $event = $message->payload();
        $this->mailer->sendWelcome($event->userId());
    }
}

Pipeline Filters

Both commands and queries support a pipeline/filter stack. Filters implement the same interface and are stacked via LinkedStack.

Creating a Filter

use Fight\Common\Application\Messaging\Command\CommandFilter;
use Fight\Common\Domain\Messaging\Command\CommandMessage;

class LoggingCommandFilter implements CommandFilter
{
    public function __construct(private LoggerInterface $logger) {}

    public function process(CommandMessage $commandMessage, callable $next): void
    {
        $command = $commandMessage->payload();
        $this->logger->info('Before: ' . $command::class);

        $next($commandMessage);

        $this->logger->info('After: ' . $command::class);
    }
}
use Fight\Common\Application\Messaging\Query\QueryFilter;
use Fight\Common\Domain\Messaging\Query\QueryMessage;

class LoggingQueryFilter implements QueryFilter
{
    // same pattern as above, but for queries
}

Wiring a Pipeline

use Fight\Common\Adapter\Messaging\Command\Sync\CommandPipeline;
use Fight\Common\Adapter\Messaging\Command\Sync\RoutingCommandBus;

$bus     = new RoutingCommandBus($router);
$pipeline = new CommandPipeline($bus);

$pipeline->addFilter(new LoggingCommandFilter());
$pipeline->addFilter(new ValidationCommandFilter());

$pipeline->execute($command);

With Symfony DI, filters are auto-wired via the CommandFilterCompilerPass / QueryFilterCompilerPass — just tag the service.


Async with Symfony Messenger

The async path sends CommandMessage / EventMessage envelopes through Symfony Messenger transports. On the consuming side, message handlers receive the envelope and forward it to the sync bus/dispatcher.

Sender Side

Bus Sends
Symfony\MessengerCommandBus CommandMessage → transport via SenderInterface
Symfony\MessengerEventDispatcher EventMessage → transport via SenderInterface

1.x Compatibility Names

The following superseded public FQCNs remain independently functional through 1.x. Each is deprecated in source only: it emits no runtime deprecation notice. New integrations should use the canonical replacement.

Superseded FQCN Canonical replacement
Fight\Common\Adapter\Messaging\Command\Async\MessengerCommandBus Fight\Common\Adapter\Messaging\Symfony\MessengerCommandBus
Fight\Common\Adapter\Messaging\Event\Async\MessengerEventDispatcher Fight\Common\Adapter\Messaging\Symfony\MessengerEventDispatcher
Fight\Common\Adapter\Messaging\Handler\SymfonyCommandMessageHandler Fight\Common\Adapter\Messaging\Handler\CommandMessageHandler
Fight\Common\Adapter\Messaging\Handler\SymfonyEventMessageHandler Fight\Common\Adapter\Messaging\Handler\EventMessageHandler
Fight\Common\Adapter\Messaging\Serializer\SymfonyMessageSerializer Fight\Common\Adapter\Messaging\Symfony\Serializer\SymfonyMessageSerializer

Receiver Side (Consuming from Transport)

CommandMessageHandler — a framework-neutral invocable handler that receives CommandMessage from the transport and forwards it to the sync SynchronousCommandBus:

final readonly class CommandMessageHandler
{
    public function __construct(private SynchronousCommandBus $commandBus) {}

    public function __invoke(CommandMessage $commandMessage): void
    {
        $this->commandBus->dispatch($commandMessage);
    }
}

EventMessageHandler — a framework-neutral invocable handler that receives EventMessage and forwards to the sync SynchronousEventDispatcher:

final readonly class EventMessageHandler
{
    public function __construct(private SynchronousEventDispatcher $eventDispatcher) {}

    public function __invoke(EventMessage $eventMessage): void
    {
        $this->eventDispatcher->dispatch($eventMessage);
    }
}

Queue integrations may call these handlers directly. Symfony registrations must tag them messenger.message_handler; the handlers themselves carry no Symfony dependency or queue policy.

Delivery and Ownership Boundaries

Queued delivery is at least once, not exactly once. A repeated EventMessage delivery forwards the same complete event occurrence to the synchronous dispatcher again, including its ordered, complete fan-out. Event subscribers must therefore tolerate retries and protect any side effects that cannot safely run more than once.

The neutral handlers only forward complete Fight messages. Each framework starter owns its own non-policy integration boundary: message-handler registration, transport and routing configuration, and the framework queue lifecycle. Broker selection, retry/backoff, worker supervision, topology, dead-letter, and outbox policy remain application concerns rather than Fight Common behavior.

Serialization

Symfony\Serializer\SymfonyMessageSerializer — implements Messenger's SerializerInterface. Uses the Domain Serializer contract to serialize/deserialize messages, and encodes Messenger stamps in X-Message-Stamp-* headers. New configuration should inject the canonical Fight\Common\Application\Serialization\JsonSerializer (or PhpSerializer); the concrete Domain\Serialization serializers are deprecated 1.x compatibility classes.

final readonly class SymfonyMessageSerializer implements SerializerInterface
{
    public function __construct(private DomainSerializer $serializer) {}

    public function decode(array $encodedEnvelope): Envelope;
    public function encode(Envelope $envelope): array;
}

The transport routing in framework:messenger must route CommandMessage and EventMessage to their respective transports:

framework:
    messenger:
        transports:
            commands: '%env(MESSENGER_TRANSPORT_DSN)%'
            events:   '%env(MESSENGER_TRANSPORT_DSN)%'
        routing:
            'Fight\Common\Domain\Messaging\Command\CommandMessage': commands
            'Fight\Common\Domain\Messaging\Event\EventMessage': events

Compiler Passes

Six compiler passes automate wiring through Symfony DI tags. All tagged services must be public because handlers are lazy-loaded on first match/dispatch.

Tag Pass Action
common.command_handler Fight\Common\Adapter\ServiceContainer\Symfony\CommandHandlerCompilerPass Calls ServiceAwareCommandRouter::registerHandler($commandClass, $serviceId)
common.command_filter Fight\Common\Adapter\ServiceContainer\Symfony\CommandFilterCompilerPass Calls CommandPipeline::addFilter(Reference)
common.query_handler Fight\Common\Adapter\ServiceContainer\Symfony\QueryHandlerCompilerPass Calls ServiceAwareQueryRouter::registerHandler($queryClass, $serviceId)
common.query_filter Fight\Common\Adapter\ServiceContainer\Symfony\QueryFilterCompilerPass Calls QueryPipeline::addFilter(Reference)
common.event_subscriber Fight\Common\Adapter\ServiceContainer\Symfony\EventSubscriberCompilerPass Calls ServiceAwareEventDispatcher::registerService($className, $serviceId)
common.template_helper Fight\Common\Adapter\ServiceContainer\Symfony\TemplateHelperCompilerPass Calls TemplateEngine::addHelper(Reference)

Each pass validates that the tagged service implements the expected interface and throws an Exception if the router/pipeline/dispatcher service is missing or the interface check fails. The matching Fight\Common\Adapter\DependencyInjection FQCN remains a deprecated 1.x compatibility identity for each pass; use the ServiceContainer\Symfony paths in new code.

Wiring in the Kernel

The cleanest approach is to use registerForAutoconfiguration in your Kernel::build() so that any service implementing the handler/filter/subscriber interface is automatically tagged:

use Fight\Common\Adapter\ServiceContainer\Symfony\CommandFilterCompilerPass;
use Fight\Common\Adapter\ServiceContainer\Symfony\CommandHandlerCompilerPass;
use Fight\Common\Adapter\ServiceContainer\Symfony\EventSubscriberCompilerPass;
use Fight\Common\Adapter\ServiceContainer\Symfony\QueryFilterCompilerPass;
use Fight\Common\Adapter\ServiceContainer\Symfony\QueryHandlerCompilerPass;
use Fight\Common\Adapter\ServiceContainer\Symfony\TemplateHelperCompilerPass;
use Fight\Common\Application\Messaging\Command\CommandFilter;
use Fight\Common\Application\Messaging\Command\CommandHandler;
use Fight\Common\Application\Messaging\Event\EventSubscriber;
use Fight\Common\Application\Messaging\Query\QueryFilter;
use Fight\Common\Application\Messaging\Query\QueryHandler;
use Fight\Common\Application\Templating\TemplateHelper;
use Symfony\Component\DependencyInjection\ContainerBuilder;

class Kernel extends BaseKernel
{
    use MicroKernelTrait;

    #[Override]
    protected function build(ContainerBuilder $container): void
    {
        $container->registerForAutoconfiguration(CommandHandler::class)->addTag('common.command_handler');
        $container->registerForAutoconfiguration(CommandFilter::class)->addTag('common.command_filter');
        $container->registerForAutoconfiguration(EventSubscriber::class)->addTag('common.event_subscriber');
        $container->registerForAutoconfiguration(QueryHandler::class)->addTag('common.query_handler');
        $container->registerForAutoconfiguration(QueryFilter::class)->addTag('common.query_filter');
        $container->registerForAutoconfiguration(TemplateHelper::class)->addTag('common.template_helper');

        $container->addCompilerPass(new CommandHandlerCompilerPass());
        $container->addCompilerPass(new CommandFilterCompilerPass());
        $container->addCompilerPass(new EventSubscriberCompilerPass());
        $container->addCompilerPass(new QueryHandlerCompilerPass());
        $container->addCompilerPass(new QueryFilterCompilerPass());
        $container->addCompilerPass(new TemplateHelperCompilerPass());
    }
}

With this approach, any service that implements, say, CommandHandler automatically receives the common.command_handler tag, and the compiler pass wires it into the router. No manual tagging is needed in services.yaml.


Full Symfony Configuration

# config/packages/common_messaging.yaml

services:
    _defaults:
        autowire: true
        autoconfigure: true

    # --- Command bus stack ---

    # Sync routing bus
    Fight\Common\Adapter\Messaging\Command\Sync\Routing\RoutingCommandBus:
        class: Fight\Common\Adapter\Messaging\Command\Sync\RoutingCommandBus
        arguments:
            - '@Fight\Common\Adapter\Messaging\Command\Sync\Routing\ServiceAwareCommandRouter'

    # Sync pipeline (decorates routing bus with filters)
    Fight\Common\Adapter\Messaging\Command\Sync\CommandPipeline:
        arguments:
            - '@Fight\Common\Adapter\Messaging\Command\Sync\Routing\RoutingCommandBus'

    # Async command bus (sends to Messenger transport)
    Fight\Common\Adapter\Messaging\Symfony\MessengerCommandBus:
        arguments:
            - '@messenger.transport.commands'

    # --- Query bus (sync only) ---

    Fight\Common\Adapter\Messaging\Query\RoutingQueryBus:
        arguments:
            - '@Fight\Common\Adapter\Messaging\Query\Routing\ServiceAwareQueryRouter'

    # --- Event dispatchers ---

    Fight\Common\Adapter\Messaging\Event\Sync\ServiceAwareEventDispatcher:
        arguments:
            - '@service_container'

    Fight\Common\Adapter\Messaging\Symfony\MessengerEventDispatcher:
        arguments:
            - '@messenger.transport.events'

    # --- Bridges: transport → sync ---

    Fight\Common\Adapter\Messaging\Handler\CommandMessageHandler:
        arguments:
            - '@Fight\Common\Adapter\Messaging\Command\Sync\CommandPipeline'
        tags:
            - { name: messenger.message_handler }

    Fight\Common\Adapter\Messaging\Handler\EventMessageHandler:
        arguments:
            - '@Fight\Common\Adapter\Messaging\Event\Sync\ServiceAwareEventDispatcher'
        tags:
            - { name: messenger.message_handler }

    # --- Message serializer ---

    Fight\Common\Adapter\Messaging\Symfony\Serializer\SymfonyMessageSerializer:
        arguments:
            - '@Fight\Common\Application\Serialization\JsonSerializer'

    # --- Event subscriber (sync, auto-registered) ---

    App\Messaging\SendWelcomeEmailSubscriber:
        tags:
            - { name: common.event_subscriber }

    # --- Command handler (auto-registered) ---

    App\Messaging\RegisterUserHandler:
        tags:
            - { name: common.command_handler }

    # --- Query handler (auto-registered) ---

    App\Messaging\GetUserHandler:
        tags:
            - { name: common.query_handler }

    # --- Filters (auto-registered into pipeline) ---

    App\Messaging\LoggingCommandFilter:
        tags:
            - { name: common.command_filter }

    App\Messaging\LoggingQueryFilter:
        tags:
            - { name: common.query_filter }

# --- Messenger transport routing ---

framework:
    messenger:
        transports:
            commands: '%env(MESSENGER_TRANSPORT_DSN)%'
            events:   '%env(MESSENGER_TRANSPORT_DSN)%'
        routing:
            'Fight\Common\Domain\Messaging\Command\CommandMessage': commands
            'Fight\Common\Domain\Messaging\Event\EventMessage': events

You should alias the bus/dispatcher interfaces to the appropriate implementations so controllers can type-hide against the interface:

services:
    Fight\Common\Application\Messaging\Command\AsynchronousCommandBus:
        alias: Fight\Common\Adapter\Messaging\Symfony\MessengerCommandBus

    Fight\Common\Application\Messaging\Query\QueryBus:
        alias: Fight\Common\Adapter\Messaging\Query\RoutingQueryBus

    Fight\Common\Application\Messaging\Event\SynchronousEventDispatcher:
        alias: Fight\Common\Adapter\Messaging\Event\Sync\ServiceAwareEventDispatcher

Data Flow Summary

Controller (async command bus)
  └── Symfony\MessengerCommandBus::execute($command)
        └── SenderInterface::send(Envelope(CommandMessage))
              ▼  (transport delivers to consumer)
        CommandMessageHandler::__invoke($commandMessage)
              └── CommandPipeline::dispatch($commandMessage)
                    └── filters...
                          └── RoutingCommandBus::dispatch($commandMessage)
                                └── CommandRouter::match($command)
                                      └── CommandHandler::handle($commandMessage)

Controller (query bus)
  └── RoutingQueryBus::fetch($query)
        └── QueryRouter::match($query)
              └── QueryHandler::handle($queryMessage)

Controller (event dispatcher)
  └── ServiceAwareEventDispatcher::trigger($event)
        └── EventMessage::create($event)
              └── handlers for event type (lazy-loaded from container)
                    └── event subscribers + added handlers

Laravel Queue capability provider

Laravel applications can register the shipped messaging capability provider in their application provider list:

use Fight\Common\Adapter\ServiceContainer\Laravel\MessagingServiceProvider;

return [
    App\Providers\AppServiceProvider::class,
    MessagingServiceProvider::class,
];

The provider binds AsynchronousCommandBus to LaravelCommandBus and AsynchronousEventDispatcher to LaravelEventDispatcher. Both submit complete Fight envelopes through Laravel's bus; handler registration, queue connection, workers, retries, and failed-job policy remain application configuration. This package provides no asynchronous query bus.

CodeIgniter Queue capability delegation

CodeIgniter applications opt into Fight Common messaging through their own app/Config/Services.php. MessagingServices is a small factory delegate; it does not replace the application's Config\Services policy and it does not activate persistence or any other Fight capability.

namespace Config;

use CodeIgniter\Config\BaseService;
use Fight\Common\Adapter\Messaging\CodeIgniter\CommandMessageJob;
use Fight\Common\Adapter\Messaging\CodeIgniter\EventMessageJob;
use Fight\Common\Adapter\ServiceContainer\CodeIgniter\MessagingServices;

final class Services extends BaseService
{
    public static function fightQueueCommandBus(bool $getShared = true)
    {
        if ($getShared) {
            return static::getSharedInstance('fightQueueCommandBus');
        }

        return MessagingServices::queueCommandBus(static::queue(), 'commands', 'fight-command');
    }

    public static function fightCommandMessageHandler(bool $getShared = true)
    {
        if ($getShared) {
            return static::getSharedInstance(CommandMessageJob::HANDLER_SERVICE);
        }

        return MessagingServices::commandMessageHandler(static::fightSynchronousCommandBus());
    }

    public static function fightEventMessageHandler(bool $getShared = true)
    {
        if ($getShared) {
            return static::getSharedInstance(EventMessageJob::HANDLER_SERVICE);
        }

        return MessagingServices::eventMessageHandler(static::fightSynchronousEventDispatcher());
    }
}

Add the analogous fightQueueEventDispatcher, fightAsynchronousCommandBus, and fightAsynchronousEventDispatcher methods only when the messaging capability is selected. The Queue package's Config\Queue owns the broker, queue names, job aliases, retries, failed-job storage, worker commands, and deployment topology. The project owns the synchronous command bus and event dispatcher collaborators shown above.

CommandMessageJob::HANDLER_SERVICE and EventMessageJob::HANDLER_SERVICE are the exact project service aliases resolved by queued jobs. Handler failures escape the job so CodeIgniter Queue's native retry and failed-job policy remain authoritative. Delivery is therefore at least once: handlers must be idempotent and repeated delivery keeps the same complete Fight message envelope.

Queue submission has no portable post-commit guarantee and is not an atomic outbox. Submit only after the selected transaction succeeds; applications requiring atomic persistence and publication need an outbox or event-store delivery design configured by the application.


Controller Examples

Command Controller (Async — HTTP 202)

use Fight\Common\Application\Messaging\Command\AsynchronousCommandBus;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;

class RegisterUserController
{
    public function __construct(
        private AsynchronousCommandBus $commandBus,
    ) {}

    public function __invoke(Request $request): Response
    {
        $command = new RegisterUserCommand(
            $request->get('email'),
            $request->get('name'),
        );

        $this->commandBus->execute($command);

        return new JsonResponse(['status' => 'accepted'], Response::HTTP_ACCEPTED);
    }
}

Query Controller (Sync — HTTP 200)

use Fight\Common\Application\Messaging\Query\QueryBus;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;

class GetUserController
{
    public function __construct(private QueryBus $queryBus) {}

    public function __invoke(Request $request): Response
    {
        $query  = new GetUserQuery($request->get('id'));
        $result = $this->queryBus->fetch($query);

        if ($result === null) {
            return new JsonResponse(['error' => 'Not found'], Response::HTTP_NOT_FOUND);
        }

        return new JsonResponse($result->toArray());
    }
}

Event Dispatch in a Service

use Fight\Common\Application\Messaging\Event\SynchronousEventDispatcher;

class RegisterUserHandler implements CommandHandler
{
    public function __construct(
        private UserRepository $users,
        private SynchronousEventDispatcher $events,
    ) {}

    public function handle(CommandMessage $commandMessage): void
    {
        $command = $commandMessage->payload();
        $user    = User::register($command->email(), $command->name());
        $this->users->save($user);

        // In async setups, use AsynchronousEventDispatcher here instead
        $this->events->trigger(new UserRegisteredEvent($user->id()));
    }
}