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.
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.
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¶
- Message Primitives
- Commands
- Queries
- Events
- Pipeline Filters
- Async with Symfony Messenger
- Compiler Passes
- Full Symfony Configuration
- 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.
MessageType¶
Fight\Common\Domain\Messaging\MessageType
A string-backed PHP enum:
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:
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¶
QueryMessage — envelope wrapping a Query. Same structure as CommandMessage with
type === '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:
EventMessage — envelope wrapping an Event. Same structure as CommandMessage/QueryMessage
with type === 'event'.
AllEvents — marker class. Event subscribers can use this instead of a specific event
class to register for every event.
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()));
}
}