alt-three/bus
Alt Three Bus is a lightweight command bus for Laravel that dispatches commands to handlers, supports middleware, and integrates with the container to keep business logic clean and decoupled.
composer require alt-three/bus
Before you install anything, open the repo's composer.json and read the illuminate/* constraints. The package is archived and its last release was July 2020, so treat Laravel 8+ as unsupported until you've proven otherwise (the internals it wraps — dispatchNow, the old BusServiceProvider — moved on).
The one required step is swapping Laravel's bus provider for the package's in config/app.php:
// Illuminate\Bus\BusServiceProvider::class,
AltThree\Bus\BusServiceProvider::class,
Then php artisan config:clear (a cached config will silently keep the old provider).
Where to look first, in this order:
src/Dispatcher.php — the actual behavior. It's small; read it before trusting the README, which is thin and predates the archive.src/CommandHandler.php — the hooks you opt into (handle, and the validation/transaction methods).README.md — installation only, basically.First use case: a write command you'd otherwise put in a controller.
final class RegisterUser
{
public function __construct(
public string $name,
public string $email,
) {}
}
final class RegisterUserHandler extends CommandHandler
{
public function handle(RegisterUser $command): User
{
return User::create(['name' => $command->name, 'email' => $command->email]);
}
public function validate(RegisterUser $command): array
{
return [
'name' => 'required|string|max:255',
'email' => 'required|email|unique:users,email',
];
}
public function transaction(RegisterUser $command): bool
{
return true;
}
}
Register the command → handler mapping from a dedicated provider (the dispatcher exposes a map/commands style registration — confirm the exact method name in src/Dispatcher.php for your tag):
$this->app->make(Dispatcher::class)->map([
RegisterUser::class => RegisterUserHandler::class,
]);
Dispatch normally: dispatch(new RegisterUser($name, $email));. You get validation-before-handle and a DB transaction around handle() for free.
Keep commands dumb, handlers fat. Commands are DTOs with public promoted properties and no logic. Handlers own the work. This is the pattern the package is built to encourage and the one that survives if you later drop the package for Laravel's native pipeline.
Validation belongs in the handler, not the controller. Return a plain rules array from validate(); the dispatcher runs it against the command's public properties before handle() is called. Combine with messages() / attributes() overrides if you need custom copy. This is how you keep FormRequests out of non-HTTP entry points (artisan commands, queue jobs, other commands).
Transactions are declarative. Return true from transaction() for the default connection, or return a closure/connection name if the package's version supports it. Anything in handle() — including nested repository calls — rolls back together.
Container injection works in both places. Handlers are resolved from the container, so constructor injection and handle(RegisterUser $command, Mailer $mailer) both work. Depend on interfaces there, bind them in a normal provider.
Test at the dispatch boundary. Assert the handler's effects, and assert validation failures explicitly:
$this->expectException(ValidationException::class);
dispatch(new RegisterUser('', 'not-an-email'));
Use DatabaseTransactions on the test class so transaction-wrapped handlers don't leak state.
Compose commands, don't nest transactions. If one command needs another's behavior, call the handler directly or use dispatchNow() to force synchronous execution and avoid transaction-in-transaction surprises.
Group the mapping by module. One BusServiceProvider per domain beats a single 200-line map in AppServiceProvider — the map is your routing table and it should read like one.
Illuminate\Contracts\Bus\Dispatcher now gets the alt-three dispatcher. If it doesn't implement every method the framework's does (chain, dispatchAfterResponse, batches), you get runtime BadMethodCallExceptions far from the cause. Check what your other packages dispatch before shipping.handle() runs in a worker. A transaction() declaration is not a guarantee that the queued execution is wrapped — read src/Dispatcher.php for your version, and when in doubt wrap the writes in DB::transaction() inside the handler.SerializesModels for Eloquent.CommandNotRegisteredException almost always means boot order or config cache. The map is registered in boot(); a deferred provider or a stale bootstrap/cache/config.php will produce a "not registered" error for a command you clearly registered. php artisan config:clear first, then app(Dispatcher::class) in tinker and dd() the map.Bus::fake() may not intercept anything. If the facade's fake swaps the contract binding, but you've rebound to the alt-three dispatcher elsewhere, your "assertDispatched" tests pass vacuously or fail confusingly. Fake at the boundary you actually use, or just assert DB/queue side effects.Dispatcher and rebind it in the container (override dispatch for logging/metrics); swap or decorate CommandValidator for custom rule resolution; wrap handlers in a base class for cross-cutting concerns (audit columns, tenant scoping) since there's no middleware pipeline.Illuminate\Bus pipeline plus a validation job middleware — the handler/command split you've already written is the hard part, and it ports over unchanged.How can I help you explore Laravel packages today?