onliner/command-bus-bundle
A Symfony bundle that provides a command bus for dispatching command objects to their handlers. It integrates the bus with Symfony's service container, letting commands and handlers be registered and resolved automatically so application code stays decoupled from the handling logic.
composer require onliner/command-bus-bundle:^0.2. Pin to 0.2.* rather than ^0.2 — this is a pre-1.0 package and minor bumps are allowed to break.composer.json first and check: does it require symfony/framework-bundle, illuminate/support, or both? Does it declare extra.laravel.providers? Does it ship a *Bundle.php or a *ServiceProvider.php? That one file determines every step below.php constraint — >=8.2, ^8.2, and ^8.1 || ^8.2 behave very differently if you're on 8.1 or 8.3.bootstrap/providers.php (Laravel 11+) or config/app.php. Publish config if offered: php artisan vendor:publish --provider="Onliner\CommandBus\...\ServiceProvider".composer.json → the provider/bundle class (this shows you the container bindings and the bus implementation class) → the Command/Handler contracts → any middleware classes shipped in the repo.handle() method, and dispatch it from the controller instead of calling a service. That's the smallest possible slice that exercises the container resolution and proves the package works in your app.// app/Application/Order/CreateOrder.php
final class CreateOrder
{
public function __construct(
public readonly string $customerId,
public readonly array $lines,
) {}
}
// app/Application/Order/CreateOrderHandler.php
final class CreateOrderHandler
{
public function __construct(private OrderRepository $orders) {}
public function handle(CreateOrder $command): string
{
return $this->orders->create($command->customerId, $command->lines)->id;
}
}
// Controller stays thin
public function store(StoreOrderRequest $request, CommandBus $bus)
{
$id = $bus->dispatch(new CreateOrder(
$request->user()->id,
$request->validated('lines'),
));
return response()->json(['id' => $id], 201);
}
One command, one handler, one reason to change. Treat each command as a use-case boundary. Commands are immutable DTOs — readonly promoted properties, scalars and value objects only. No Eloquent models, no Request, no Auth::user() captured inside. Resolve those before dispatch and pass primitives, otherwise async execution silently serializes stale or unserializable state.
Keep the bus out of your domain. Inject the bus interface into controllers, console commands, and queued jobs only. Handlers should dispatch other commands sparingly; if you find yourself chaining three deep, you wanted a saga or an event, not a bus.
Middleware is where the day-to-day value lives. The pluggable pipeline is the reason to adopt this over hand-rolled new XHandler() calls. Typical Laravel order, outer to inner:
DB::transaction(fn () => $next($command)) for commands that write; leave queries out of the pipeline.Gate::authorize() against a policy keyed off the command class.Sync vs. AMQP — split by intent, not by size.
| Path | Use for | Returns |
|---|---|---|
| Sync (in-process) | Queries, fast writes, anything the caller needs a result from | Handler return value |
| AMQP | Slow side effects: emails, PDFs, third-party calls, fan-out | void |
If a command needs a return value, it can't be async. Design the API so the controller gets an ID or a 202 Accepted and a job-status endpoint, rather than trying to poll the bus for a result.
// Routing decision belongs in config, not in the controller.
// config/command_bus.php (shape to confirm against source)
return [
'async' => [
\App\Application\Order\CreateOrder::class,
\App\Application\Mail\SendReceipt::class,
],
];
Testing. The single highest-leverage habit: swap the real bus for an in-memory implementation in feature tests. Assert intent, not internals.
public function test_checkout_dispatches_create_order(): void
{
$this->instance(CommandBus::class, $bus = new InMemoryCommandBus());
$this->postJson('/orders', [...])->assertCreated();
$bus->assertDispatched(CreateOrder::class, fn ($c) => $c->customerId === 'u_1');
}
Keep a separate, smaller suite that boots the real bus with the real container and runs a happy-path command end-to-end. That catches the class of bug where auto-resolution fails only in production wiring.
Folder layout that ages well — group by feature, not by layer:
app/Application/Order/
CreateOrder.php
CreateOrderHandler.php
CancelOrder.php
CancelOrderHandler.php
Handler registration. Prefer whatever the package calls explicit binding over relying on autowiring. Auto-resolution via the container usually works for zero-arg or type-hinted constructors, but a handler whose dependencies are interfaces needs explicit bind() calls. Do it once in a dedicated CommandBusServiceProvider so upgrade diffs are localised.
extra.laravel.providers), there is no auto-discovery and possibly no Laravel-friendly container contract. Don't burn a day debugging a service provider that doesn't exist — read the source tree before installing.0.x means no compatibility promise. Read the diff between tags before every upgrade, not the release note. One line saying "PHP 8.2 support" could mean the PHP constraint widened (safe) or narrowed to ^8.2 only (locks out 8.3+). composer why-not onliner/command-bus-bundle 0.2.0 tells you which.readonly.json_encode/unserialize round-trip in a unit test for every async command.SendReceipt will email twice. Put a dedupe key on the command and a unique index on the consuming side, or accept duplicates deliberately.failed handler equivalent and alarm on the DLQ from day one, before you have traffic.command, correlation_id, duration_ms, outcome fields. For the wiring itself: php artisan about, php artisan package:discover, php artisan config:clear, composer dump-autoload.How can I help you explore Laravel packages today?