“Put every use case behind a command bus” sounds disciplined until changing one model needs a command, a handler, a mapping and a dispatch call. The opposite extreme is no better: a controller that validates input, changes an order, takes a lock, queues mail and calls a provider has no useful boundary either.
Laravel offers actions, jobs, events and the Bus dispatcher. They overlap, but they do not mean the same thing. This article reserves limited inventory to draw a pragmatic line: start with an action for work needed in this process; introduce a command and handler when dispatch semantics are valuable; use a queue when work may happen later; publish an event only as a fact that other parts of the application may react to.
Begin with the smallest useful boundary
An invokable action is a named application operation. It accepts domain input,
coordinates dependencies and returns a meaningful result. It is neither an
Eloquent model method nor a generic OrderService dumping ground.
An admin reserving stock needs an answer before the browser redirects. A synchronous action is therefore the natural default.
<?php
declare(strict_types=1);
namespace App\Orders\Actions;
use App\Models\Order;
use App\Models\Product;
use Illuminate\Support\Facades\DB;
final class ReserveOrderInventory
{
public function __invoke(Order $order): void
{
DB::transaction(function () use ($order): void {
$order = Order::query()->lockForUpdate()->findOrFail($order->id);
if ($order->inventory_reserved_at !== null) {
return;
}
foreach ($order->items as $item) {
$product = Product::query()->lockForUpdate()->findOrFail($item->product_id);
if ($product->stock < $item->quantity) {
throw new InsufficientInventory($product);
}
$product->decrement('stock', $item->quantity);
}
$order->forceFill(['inventory_reserved_at' => now()])->save();
}, attempts: 3);
}
}
The transaction and locks are business protection, not Bus features. A second
request can arrive before the first commits; without a row lock both requests
may see the same quantity. The action is also idempotent locally: another call
sees inventory_reserved_at and cannot decrement stock a second time.
The controller owns HTTP concerns—authorization and validation—not reservation rules.
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\Order;
use App\Orders\Actions\ReserveOrderInventory;
use Illuminate\Http\RedirectResponse;
final class ReserveOrderInventoryController extends Controller
{
public function __invoke(Order $order, ReserveOrderInventory $reserve): RedirectResponse
{
$this->authorize('reserveInventory', $order);
$reserve($order);
return to_route('admin.orders.show', $order)->with('success', 'Inventory reserved.');
}
}
This is already one use case per class. Do not make it a command just because it has a name.
A command describes intent; its handler performs it
The boundary changes when a controller, scheduled command and integration can all request the same operation, and shared dispatch policies are useful. A command is a small serializable message: IDs, scalar values and a correlation key—not a Request, closure or loaded relationship graph.
<?php
declare(strict_types=1);
namespace App\Orders\Commands;
final readonly class ReserveInventory
{
public function __construct(
public int $orderId,
public string $reservationKey,
) {
}
}
The handler loads fresh state at execution time and delegates the core domain operation to the action. Reusing the action prevents a future synchronous path from gaining a slightly different reservation implementation.
<?php
declare(strict_types=1);
namespace App\Orders\CommandHandlers;
use App\Models\Order;
use App\Orders\Actions\ReserveOrderInventory;
use App\Orders\Commands\ReserveInventory;
final class ReserveInventoryHandler
{
public function __construct(private ReserveOrderInventory $reserveOrderInventory)
{
}
public function handle(ReserveInventory $command): void
{
$order = Order::query()->findOrFail($command->orderId);
($this->reserveOrderInventory)($order);
}
}
Map the command once. The mapping is valuable because the message deliberately does not know its implementation; it is not ceremony for a class that could simply call an action.
<?php
declare(strict_types=1);
namespace App\Providers;
use App\Orders\CommandHandlers\ReserveInventoryHandler;
use App\Orders\Commands\ReserveInventory;
use Illuminate\Contracts\Bus\Dispatcher;
use Illuminate\Support\ServiceProvider;
final class CommandBusServiceProvider extends ServiceProvider
{
public function boot(Dispatcher $bus): void
{
$bus->map([
ReserveInventory::class => ReserveInventoryHandler::class,
]);
}
}
The caller expresses intent instead of selecting an implementation:
$bus->dispatch(new ReserveInventory(
orderId: $order->id,
reservationKey: (string) $request->header('Idempotency-Key'),
));
dispatchSync() suits a caller that needs the handler result now. dispatch()
is not automatically asynchronous: it becomes queued only when the dispatched
message is queueable. Seeing the Bus facade does not mean the HTTP response has
been decoupled from the work.
Middleware is for cross-cutting policy
The bus can send mapped commands through middleware. That is appropriate for a stable policy such as adding correlation context, auditing accepted commands or rejecting writes during maintenance. It is a poor home for order rules, because hidden middleware makes a failed reservation hard to explain.
<?php
declare(strict_types=1);
namespace App\Orders\Bus;
use Closure;
use Illuminate\Log\LogManager;
final class LogCommand
{
public function __construct(private LogManager $log)
{
}
public function handle(object $command, Closure $next): mixed
{
$this->log->info('Dispatching command', ['command' => $command::class]);
return $next($command);
}
}
$bus->pipeThrough([
\App\Orders\Bus\LogCommand::class,
]);
Keep the pipeline short and observable. One that quietly starts transactions, authorizes users and catches every exception duplicates Laravel's normal boundaries and becomes surprising in CLI commands and workers.
Queue slow or retryable work deliberately
Reservation is normally synchronous. Informing a warehouse API is not. Make the queue boundary explicit with a job that stores only an order ID, tolerates retries and is released after the reservation transaction commits.
<?php
declare(strict_types=1);
namespace App\Orders\Jobs;
use App\Models\Order;
use App\Services\WarehouseClient;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldBeUnique;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\Middleware\RateLimited;
use Illuminate\Queue\SerializesModels;
use Throwable;
final class NotifyWarehouseOfReservation implements ShouldQueue, ShouldBeUnique
{
use Dispatchable;
use InteractsWithQueue;
use Queueable;
use SerializesModels;
public int $tries = 5;
public function __construct(public int $orderId)
{
}
public function uniqueId(): string
{
return 'warehouse-reservation:'.$this->orderId;
}
public function backoff(): array
{
return [5, 30, 120];
}
public function middleware(): array
{
return [new RateLimited('warehouse-api')];
}
public function handle(WarehouseClient $warehouse): void
{
$order = Order::query()->findOrFail($this->orderId);
$warehouse->reserve(
externalReference: (string) $order->id,
items: $order->items->map(fn ($item): array => [
'sku' => $item->product->sku,
'quantity' => $item->quantity,
])->all(),
);
}
public function failed(Throwable $exception): void
{
report($exception);
}
}
Dispatch it from the action only after local state is committed:
NotifyWarehouseOfReservation::dispatch($order->id)->afterCommit();
Uniqueness reduces duplicate queued work; it cannot make an HTTP API exactly
once. A worker can crash after a provider accepts the request but before it
acknowledges the job. The warehouse endpoint needs its own idempotency key, and
the application should persist delivery state where the result matters. Set a
queue connection's retry_after higher than the job timeout too, otherwise a
slow worker may be processed twice.
Test the boundary that matters
Test the action for transactional behaviour. Test dispatching separately:
Bus::fake() proves intent, not that a handler works. These focused Pest tests
state both truths.
<?php
use App\Models\Order;
use App\Models\Product;
use App\Orders\Actions\ReserveOrderInventory;
use App\Orders\Commands\ReserveInventory;
use Illuminate\Support\Facades\Bus;
it('reserves inventory only once for an order', function () {
$product = Product::factory()->create(['stock' => 10]);
$order = Order::factory()->hasItems(1, [
'product_id' => $product->id,
'quantity' => 3,
])->create();
$reserve = app(ReserveOrderInventory::class);
$reserve($order);
$reserve($order->fresh());
expect($product->fresh()->stock)->toBe(7)
->and($order->fresh()->inventory_reserved_at)->not->toBeNull();
});
it('dispatches a reservation command', function () {
Bus::fake();
Bus::dispatch(new ReserveInventory(orderId: 42, reservationKey: 'checkout-42'));
Bus::assertDispatched(ReserveInventory::class, fn (ReserveInventory $command): bool =>
$command->orderId === 42 && $command->reservationKey === 'checkout-42'
);
});
For the handler, call handle() with factories or resolve the dispatcher in an
integration test without faking it. For the warehouse job, fake the HTTP client
and assert the idempotency header; a queue fake cannot reveal a malformed remote
request.
Failure modes the abstraction does not remove
A command object is not a transaction boundary. If a handler changes an order, writes an outbox record and then requests a remote payment, no amount of class separation makes all three systems commit together. First complete the local transaction. Then deliver the external effect asynchronously, carrying an idempotency key that the receiver understands. For a business-critical flow, an outbox table gives the worker a durable record of work that must eventually leave the database.
Do not put an authenticated user object in the command and hope a queue can reconstruct its permissions tomorrow. Authorize at the entry point while the request context exists, store the actor ID only when an audit trail needs it, and make the handler apply domain-level authorization if it can also be called from an untrusted integration. Equally, never rely on a model instance captured at dispatch time: the record may be deleted, changed, or no longer satisfy the operation's preconditions by the time a worker reaches it.
Retries need classification. A 429 or a connection timeout may deserve a
backoff; an invalid warehouse SKU should fail quickly and create an actionable
alert. Catching every Throwable and returning successfully converts a visible
failed job into lost work. The failed() hook is for reporting or recovery, not
for pretending the side effect happened. Make timeout, retry count, backoff and
queue priority conscious operational choices, then observe failed-job volume and
age in Horizon or the queue dashboard.
Finally, avoid a command bus for a disguised query. GetOrderSummaryCommand
with a handler is often less clear than a query object or repository method
whose caller receives data directly. Commands change state or request a process;
queries answer questions. Keeping that distinction preserves readable call
sites and stops a generic bus from becoming the application's only dependency
injection mechanism.
Actions, commands, jobs and events are not interchangeable
Use an action for an operation a known caller needs now. Use a command
and handler where its message and dispatch pipeline form a useful boundary. Use
a job for work that may wait, retry or run on another worker. Use an
event after a fact—InventoryReserved—when independent listeners may
respond. Do not use an event as a request for one mandatory operation: without
a listener that operation silently vanishes.
Most CRUD use cases need only an action and Form Request. A separate command
handler for ChangeProfilePhoto gains little when called once, returning now,
with no shared policy. Likewise, do not queue a database write just to feel
asynchronous when the next page needs its result.
The pattern earns its cost when execution semantics are genuinely variable or cross-cutting. Name the use case, keep its message small, make side effects idempotent, and choose the simplest boundary that preserves those truths.