jeckel-lab/command-dispatcher-bundle
A Symfony bundle providing a lightweight command dispatcher for decoupling actions from their handlers. Dispatch command objects through a configurable bus, add middleware for cross-cutting concerns, and keep controllers thin with clean, testable service wiring.
Install and register the bundle
composer require jeckel-lab/command-dispatcher-bundle:^0.4
Pin the minor line — as of 0.4.1 the only change is a composer.json dependency fix, so constraint resolution can shift between patches. Run composer why-not jeckel-lab/command-dispatcher-bundle 0.4.1 if the resolver still picks an older version; it will tell you which of your pinned packages is blocking the upgrade.
Register it in config/bundles.php (Symfony Flex won't do this automatically for a low-traffic package — verify it was added):
return [
// ...
JeckelLab\CommandDispatcherBundle\CommandDispatcherBundle::class => ['all' => true],
];
First look
DependencyInjection/Configuration.php to see the actual config tree — this is the source of truth for what you can tune (handler tag name, middleware list, service IDs).Resources/config/services.yaml (or services.php) inside the bundle to learn the service ID you should inject. In most versions it's command_dispatcher (aliased to JeckelLab\CommandDispatcher\CommandDispatcherInterface / CommandBus).First use case — a synchronous command
// src/Application/Command/CreateUser.php
final class CreateUser
{
public function __construct(
public readonly string $email,
public readonly string $name,
) {}
}
// src/Application/Command/CreateUserHandler.php
use JeckelLab\CommandDispatcher\CommandHandler\CommandHandlerInterface;
final class CreateUserHandler implements CommandHandlerInterface
{
public function __construct(private UserRepository $users) {}
public function __invoke(CreateUser $command): void
{
$this->users->save(new User($command->email, $command->name));
}
}
// src/Controller/UserController.php
public function __invoke(Request $request, CommandDispatcherInterface $dispatcher): Response
{
$dispatcher->dispatch(new CreateUser($request->get('email'), $request->get('name')));
return new Response(null, 201);
}
If the handler doesn't get picked up, the compiler pass is the culprit — see Gotchas.
Commands are plain DTOs — keep them free of behaviour
Treat commands as immutable value objects (readonly properties, no setters, no repository dependencies). All side effects belong in the handler. This makes them trivially serialisable later if you move to async.
One handler per command, typed via the handle/__invoke argument
The dispatcher resolves the handler by the parameter type of the method it inspects. Use a specific command class, not an interface or object, otherwise you'll get "multiple handlers for X" or "no handler" errors.
Dispatching from controllers, console commands, and listeners
Inject CommandDispatcherInterface (or the concrete bus) anywhere. Prefer dispatching from a thin controller/console command and keeping the handler as the single entry point to your domain — this is the biggest win of the pattern, because the same command can be triggered from HTTP, a message consumer, or a scheduled task.
Middleware for cross-cutting concerns
Wire middleware for transactions, logging, and validation:
// config/services.yaml
services:
App\CommandBus\TransactionMiddleware:
tags: ['command_dispatcher.middleware'] # verify tag name in the bundle
Transaction middleware pattern — wrap dispatch() in beginTransaction/commit/rollback. Because the bundle routes every command through the same pipeline, you get consistent transactional boundaries without touching each handler.
Bridging to Symfony Messenger for async
When a command needs to run off the request thread, the common integration is: keep the command/handler pair, but publish the command as a Messenger message from the controller, and have the Messenger consumer call the dispatcher. That way the handler code is unchanged and the bundle still owns handler resolution.
final class CreateUserMessageHandler
{
public function __construct(private CommandDispatcherInterface $dispatcher) {}
public function __invoke(CreateUserMessage $message): void
{
$this->dispatcher->dispatch(new CreateUser($message->email, $message->name));
}
}
Testing
Handlers are just classes — instantiate them with mocks/fakes in unit tests. Test the dispatch pipeline separately by booting a small KernelTestCase and asserting the handler was invoked (spy handler, or assert DB state). Avoid mocking CommandDispatcherInterface in handler tests; mock it only in controller tests.
Console command wrapper for ops
Expose the same commands via a generic console command (app:command:dispatch <command> <json>) so you can replay a failed command in production without a deploy. Cheap to write, and it turns the command bus into a debugging tool.
Low adoption, low maintenance — plan accordingly
1 star, 0 dependents, last release December 2021. The 0.4.1 patch is a composer.json dependency fix only — no code, no API change — which tells you the package is still being touched, but barely. Audit the source before relying on it in a critical path. It's small enough (Symfony bundle + small dispatcher) that forking or vendoring is realistic if upstream goes quiet.
0.4.1 changed dependency constraints — treat it as an environment change
A composer.json fix means the resolved dependency graph can differ from 0.4.0 even though no PHP changed. After upgrading:
composer update jeckel-lab/command-dispatcher-bundle --with-dependencies rather than assuming a no-op.Handler registration is a compiler pass — clear the cache
If a new handler yields "no handler found", it's almost always the container cache or autoconfigure tags. Run bin/console cache:clear and bin/console debug:container --tag=<handler-tag> to confirm the tag. Also verify autoconfigure: true is set for your App\ namespace — if you disabled it, handlers won't get the tag automatically.
No return value contract
Most dispatchers of this shape return void or mixed from dispatch(). If you need a query result, don't abuse commands — use a separate query bus or return the value explicitly and document it. Mixing "command" and "query" semantics in the same bus makes async migration painful.
Nested dispatch and recursion
Dispatching a command from inside a handler works (middleware runs again), but transaction middleware will nest. Guard against double-begin with a stack depth check, or make the outer command the only transactional one. Also watch for infinite loops if command A's handler dispatches A.
Middleware runs in registration order
If you add transaction + logging, logging will capture pre- or post-transaction state depending on order. Read the bundle's compiler pass to confirm ordering (priority attribute if supported, otherwise YAML order). Test the pipeline in integration, not just unit tests.
Exception propagation
By default exceptions bubble out of dispatch() — that's what you want. But if there's an error-handling middleware, make sure it rethrows or returns a typed error. Swallowed exceptions in a command bus are silent data-loss bugs.
Extension points to inspect
DependencyInjection/Compiler/…) — how handlers are collected and whether you can plug a custom handler locator.CommandDispatcherInterface with a decorator is the cleanest way to add metrics/tracing without touching the bundle.Debugging checklist when dispatch fails
bin/console debug:container <handler-class> — is it registered?bin/console debug:container --tag=<handler-tag> — is the tag applied?How can I help you explore Laravel packages today?