illuminate/bus
Laravel's command bus component. Dispatch jobs and commands through a central pipeline with support for synchronous handling, queue dispatching, and middleware-style pipes that wrap execution. Includes the dispatch() helper and Dispatchable trait used throughout Laravel.
You almost certainly already have it — don't install it. illuminate/bus is a read-only subtree split of laravel/framework. In any Laravel app it is already present as part of laravel/framework, auto-discovered through Illuminate\Bus\BusServiceProvider, and bound as Illuminate\Contracts\Bus\Dispatcher (aliased to Illuminate\Bus\Dispatcher). Running composer require illuminate/bus alongside laravel/framework is the classic way to create a duplicate-class/conflict mess. The only legitimate case for consuming the split directly is building your own Laravel-flavoured micro-framework — and even then, symfony/messenger is usually the better answer.
Where to look first, in vendor/laravel/framework/src/Illuminate/Bus/:
| File | What it gives you |
|---|---|
Dispatcher.php |
dispatch, dispatchSync, dispatchToQueue, dispatchAfterResponse, chain, batch, pipeThrough, map — read this file end to end once; it's ~200 lines and explains every behaviour you'll ever debug |
PendingDispatch.php |
The fluent object returned by MyCommand::dispatch(); it dispatches from __destruct |
Queueable.php |
onQueue, onConnection, delay, afterCommit, chain, allOnQueue |
Batchable.php, Batch.php, UniqueLock.php |
Batching and ShouldBeUnique lock handling |
Note what is not in this package: the Dispatchable trait (Illuminate\Foundation\Bus\Dispatchable, which gives you the static Command::dispatch() helpers) lives in illuminate/foundation, and Bus::fake() lives in illuminate/support. The bus package is the engine; the ergonomics come from the framework.
First use case — move a controller's write path into a command. Start with one command per use case, not per model:
namespace App\Commands;
use App\Models\Order;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Bus\Queueable;
use Illuminate\Queue\SerializesModels;
class ProcessOrder
{
use Dispatchable, Queueable, SerializesModels;
public function __construct(public Order $order) {}
public function handle(PaymentGateway $gateway): void
{
$gateway->charge($this->order);
$this->order->markAsProcessed();
}
}
// Controller
ProcessOrder::dispatch($order); // queued if ShouldQueue, else inline
ProcessOrder::dispatchSync($order); // force inline, current process
Then immediately write the test that proves intent, before you have twenty of these:
Bus::fake();
$this->post("/orders/{$order->id}/process");
Bus::assertDispatched(ProcessOrder::class, fn ($c) => $c->order->is($order));
Once that loop feels natural, add implements ShouldQueue and let the queue do the work. That's the whole ramp-up.
Self-handling commands vs. separate handlers. For most apps, putting handle() on the command is simpler and keeps the use case in one file. Split into a handler class when the command is a pure DTO shared across entry points (HTTP, console, queue consumer), or when the logic is large enough to warrant its own test surface. If you split, either follow the naming convention (GenerateInvoice → GenerateInvoiceHandler) or wire it explicitly — explicit wins in large codebases:
// AppServiceProvider::boot()
Bus::map([
\App\Commands\GenerateInvoice::class => \App\Handlers\GenerateInvoiceHandler::class,
]);
Fluent pending dispatch for queue routing. The static dispatch() returns a PendingDispatch, so queue concerns stay next to the call site:
SendInvoiceEmail::dispatch($invoice)
->onConnection('redis')
->onQueue('mail')
->delay(now()->addMinutes(5))
->afterCommit();
afterCommit() (or implements ShouldQueueAfterCommit) is the pattern you want almost everywhere in a transactional app — without it a worker can pick up a job for a row that gets rolled back.
Chains and batches are the same API surface. Use them instead of hand-rolling orchestration:
Bus::chain([
new ChargeCard($order),
new SendReceipt($order),
new NotifyWarehouse($order),
])->onQueue('orders')->dispatch();
Bus::batch([new ImportRow($r1), new ImportRow($r2)])
->then(fn () => ImportCompleted::dispatch())
->catch(fn ($batch, $e) => Log::error($e->getMessage()))
->finally(fn () => Cache::forget('import'))
->dispatch();
Batches need the job_batches table (php artisan queue:batches-table && migrate); chains do not. A chain stops on the first failure by default.
Middleware via pipeThrough, registered once. Command middleware is global to the dispatcher — there is no per-command attribute. Register in a service provider's boot():
Bus::pipeThrough([
\App\Bus\Middleware\LogCommand::class,
\App\Bus\Middleware\AuthorizeCommand::class,
]);
Each is a small class with handle($command, $next) that must return $next($command). To scope middleware to specific commands, do the instanceof check inside the middleware rather than registering different pipelines — the dispatcher is a singleton, so mutating pipeThrough per-request leaks between requests in Octane and long-running workers.
Testing: fake the bus, not the handler. Bus::fake() prevents the handler from running at all, so you assert intent in feature tests and get real coverage of the handler in a plain unit test that instantiates it directly:
Bus::fake();
// ... act ...
Bus::assertDispatchedSync(ProcessOrder::class); // dispatchSync() was used
Bus::assertDispatchedTimes(ProcessOrder::class, 1);
Bus::assertNotDispatched(RefundOrder::class);
Bus::assertChained([...]); // for Bus::chain()
Bus::assertBatched(fn ($batch) => $batch->jobs->count() === 2);
assertDispatched covers the async path; assertDispatchedSync is a separate assertion and a very common source of "why is my fake failing" head-scratching.
Guard rails around side effects. implements ShouldBeUnique (backed by a cache lock driver that supports locks — redis, database, memcached) plus uniqueId() is the cheap way to stop duplicate charge jobs when a user double-clicks. It only applies to queued dispatch.
dispatch() and Command::dispatch() are not the same thing. The global helper dispatch($command) dispatches immediately and returns the handler's return value; the static ProcessOrder::dispatch() returns a PendingDispatch that only fires when it is garbage-collected. Consequences: (a) dispatch($cmd)->onQueue('x') is a fatal error — you're calling a method on the handler's return value; (b) if you return $pendingDispatch from a controller and something holds a reference, the dispatch is deferred past your assertions; (c) ->afterResponse() and ->delay() only exist on the static form. Use the static form as house style.
Handler resolution fails loudly, but late. If a command has no handle() method and no registered handler exists at the conventional name, you get a container exception at dispatch time — not at boot, not at lint. Rename or Bus::map() it. If you do have handle(), it always wins over a separate handler class, so a leftover stub method will silently shadow the real handler you just wrote.
handle() parameters are container-resolved positional dependencies. Anything you type-hint there is injected — services yes, but also an Eloquent model if you accidentally type-hint one, which will resolve to an empty model instance rather than null. Keep handle() signatures to constructor data plus explicit services; leave nullable/optional arguments out entirely rather than typing them as ?Foo.
dispatchSync() still respects queued commands. It forces the sync connection rather than skipping the queue path, so queue middleware and events still run. That's usually what you want in tests — but it also means a ShouldQueue command under dispatchSync() runs inside your transaction, with all the visibility consequences.
QUEUE_CONNECTION=sync hides every async bug. In local dev, queued commands execute inline, so serialization errors, SerializesModels omissions, and timeout issues never surface. Keep a redis/database connection in config/queue.php and actually run php artisan queue:work before you ship. Include SerializesModels in every command that carries a model into the queue; without it you get a full model snapshot that goes stale (and can break on attribute/format changes).
Never dispatch a command from within its own handler — with PendingDispatch's destructor semantics the recursion is easy to create accidentally via a "retry" path. Guard with ShouldBeUnique or an explicit status check.
Middleware that forgets return $next($command) returns null and short-circuits the pipeline with no error. Symptom: handlers mysteriously never run. First debugging step is always to re-read the middleware chain, not the handler.
Debugging queued command failures. Failed jobs land in failed_jobs (php artisan queue:failed) and, for batches, in job_batches. Hook Illuminate\Queue\Events\JobProcessing / JobExceptionOccurred to get structured tracing, or drop a LogCommand middleware into pipeThrough to timestamp every dispatch and its payload — cheap and pays for itself immediately.
Extending without monkey-patching. Bind your own dispatcher in a service provider's register() ($this->app->singleton(DispatcherContract::class, ...)) when you need correlation IDs or audit logging on every dispatch, rather than decorating the Bus facade. Keep the custom class extending Illuminate\Bus\Dispatcher so chain(), batch(), and dispatchAfterResponse() continue to work.
Version alignment. The split's composer.json pins illuminate/* to the matching framework major. Mixing an illuminate/bus from one major with a laravel/framework from another is a dependency conflict at best and a subtle behavioural drift at worst — the dispatcher's internals (dispatchSync semantics, ShouldBeUnique enforcement) have changed between majors. Always take it from the framework.
How can I help you explore Laravel packages today?