„Każdy use case za command busem” brzmi zdyscyplinowanie, dopóki zmiana jednego modelu nie wymaga komendy, handlera, mapowania i wywołania dispatch. Druga skrajność też nie jest lepsza: kontroler, który waliduje dane, zmienia zamówienie, zakłada blokadę, kolejkuje mail i wywołuje dostawcę, nie ma żadnej użytecznej granicy.
Laravel oferuje akcje, joby, eventy i dispatcher Bus. Nakładają się, ale nie znaczą tego samego. Granicę wyznaczam tak: zacznij od akcji dla pracy potrzebnej w tym procesie; dodaj komendę i handler, gdy przydaje się semantyka dispatchingu; użyj kolejki, gdy praca może zajść później; publikuj event tylko jako fakt, na który inne części aplikacji mogą zareagować.
Zacznij od najmniejszej użytecznej granicy
Wywoływalna akcja jest nazwaną operacją aplikacyjną. Przyjmuje dane domenowe,
koordynuje zależności i zwraca znaczący wynik. Nie jest ani metodą modelu
Eloquent, ani ogólnym śmietnikiem pod nazwą OrderService.
Administrator rezerwujący stan potrzebuje odpowiedzi, zanim przeglądarka wykona przekierowanie. Synchroniczna akcja jest więc naturalnym wyborem domyślnym.
<?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);
}
}
Transakcja i blokady są ochroną biznesową, nie funkcjami Busa. Drugi request
może przyjść przed commitem pierwszego; bez blokady wiersza oba requesty mogą
zobaczyć tę samą liczbę sztuk. Akcja jest też lokalnie idempotentna: kolejne
wywołanie widzi inventory_reserved_at i nie może zmniejszyć stanu drugi raz.
Kontroler odpowiada za autoryzację i walidację, a nie za reguły rezerwacji.
<?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.');
}
}
To już jest jeden use case na klasę. Nie rób z niego komendy wyłącznie dlatego, że ma nazwę.
Komenda opisuje intencję, handler ją wykonuje
Granica zmienia się wtedy, gdy ten sam proces może uruchomić kontroler,
zaplanowana komenda i integracja, a wspólne polityki dispatchingu są użyteczne.
Komenda jest małym serializowalnym komunikatem: ID, skalary i klucz korelacji,
nie Request, closure ani załadowany graf relacji.
<?php
declare(strict_types=1);
namespace App\Orders\Commands;
final readonly class ReserveInventory
{
public function __construct(
public int $orderId,
public string $reservationKey,
) {
}
}
Handler pobiera aktualny stan w chwili wykonania i deleguje główną operację domenową do akcji. Ponowne użycie akcji zapobiega temu, by przyszła ścieżka synchroniczna zyskała trochę inną implementację rezerwacji.
<?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);
}
}
Mapuj komendę tylko raz. Mapowanie ma sens tylko dlatego, że komunikat celowo nie zna implementacji.
<?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,
]);
}
}
Wywołujący wyraża intencję, zamiast wybierać implementację:
$bus->dispatch(new ReserveInventory(
orderId: $order->id,
reservationKey: (string) $request->header('Idempotency-Key'),
));
dispatchSync() pasuje tam, gdzie wywołujący potrzebuje wyniku handlera od
razu. dispatch() nie jest automatycznie asynchroniczny: staje się kolejką
dopiero, gdy wysyłany komunikat jest queueable.
Middleware służy polityce przekrojowej
Bus może przesłać zmapowane komendy przez middleware. Jest to dobre miejsce na stabilną politykę: dodanie kontekstu korelacji, audyt zaakceptowanych komend lub odrzucanie zapisów podczas maintenance. To słabe miejsce dla reguł zamówienia, bo ukryty middleware utrudnia wyjaśnienie nieudanej rezerwacji.
<?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,
]);
Pipeline utrzymuj krótki i obserwowalny. Taki, który po cichu otwiera transakcje, autoryzuje użytkowników i łapie wszystkie wyjątki, dubluje zwykłe granice Laravela i zaskakuje w komendach CLI oraz workerach.
Kolejkuj wolną lub ponawialną pracę świadomie
Rezerwacja z reguły pozostaje synchroniczna. Powiadomienie API magazynu nie. Uczyń granicę kolejki jawną jobem, który przechowuje wyłącznie ID zamówienia, dobrze znosi retry i zostaje zwolniony po commicie transakcji rezerwacji.
<?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 int $uniqueFor = 3600;
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);
}
}
Wyślij job z akcji dopiero po bezpiecznym commicie lokalnego stanu:
NotifyWarehouseOfReservation::dispatch($order->id)->afterCommit();
Unikalnemu jobowi ustaw wygasanie przez uniqueFor: Laravel zakłada blokadę już przy dispatch(), a afterCommit() tylko opóźnia wysłanie do kolejki, więc po wycofanej transakcji blokada zostałaby bez żadnego zadania w kolejce.
Unikalność ogranicza zduplikowaną pracę w kolejce; nie uczyni API HTTP dokładnie
jednorazowym. Worker może paść po akceptacji requestu przez dostawcę, lecz przed
potwierdzeniem joba. Endpoint magazynu potrzebuje własnego klucza idempotencji,
a aplikacja powinna utrwalać stan dostarczenia tam, gdzie wynik ma znaczenie.
Ustaw też retry_after połączenia kolejki wyżej niż timeout joba, inaczej wolny
worker może być przetworzony dwa razy.
Testuj granicę, która ma znaczenie
Testuj akcję dla zachowania transakcyjnego. Dispatching testuj osobno:
Bus::fake() potwierdza intencję, a nie działanie handlera. Poniższe testy Pest obejmują jedno i drugie.
<?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'
);
});
Dla handlera wywołaj handle() z factory albo rozwiąż dispatcher w teście
integracyjnym bez fake'a. Dla joba magazynowego użyj fake'a klienta HTTP i
sprawdź nagłówek idempotencji; fake kolejki nie odkryje źle zbudowanego requestu
zdalnego.
Awaria, której abstrakcja nie usuwa
Obiekt komendy nie jest granicą transakcji. Jeżeli handler zmienia zamówienie, zapisuje rekord outboxa, a następnie prosi zdalną płatność, żaden podział na klasy nie sprawi, że trzy systemy zatwierdzą się razem. Najpierw zakończ lokalną transakcję. Następnie dostarcz zewnętrzny efekt asynchronicznie, przekazując klucz idempotencji rozumiany przez odbiorcę. W biznesowo krytycznym przepływie tabela outbox daje workerowi trwały zapis pracy, która ma finalnie opuścić bazę.
Nie umieszczaj uwierzytelnionego obiektu użytkownika w komendzie z nadzieją, że kolejka odtworzy jego uprawnienia jutro. Autoryzuj w punkcie wejścia, gdy kontekst requestu istnieje; zapisuj tylko ID aktora, jeśli ślad audytowy go potrzebuje; a gdy handler może być też wywołany przez niezaufaną integrację, zastosuj w nim autoryzację domenową. Nie polegaj również na instancji modelu przechwyconej przy dispatchu: rekord może zostać usunięty, zmieniony lub przestać spełniać warunki operacji, zanim worker do niego dotrze.
Retry wymagają klasyfikacji. 429 albo timeout połączenia może zasługiwać na
backoff; nieprawidłowe SKU magazynu powinno szybko zakończyć job błędem i
utworzyć alert możliwy do obsługi. Łapanie każdego Throwable i zwracanie
sukcesu zmienia widoczny failed job w utraconą pracę. Hook failed() służy do
raportowania albo odzyskania stanu, nie do udawania, że efekt uboczny nastąpił.
Ustaw timeout, liczbę prób, backoff i priorytet kolejki świadomie, a liczbę
i wiek failed jobów obserwuj w Horizon lub dashboardzie kolejek.
Na końcu: nie stosuj command busa dla zamaskowanego odczytu. Handler dla
GetOrderSummaryCommand często jest mniej czytelny niż query object albo metoda
repozytorium, która zwraca dane bezpośrednio do wywołującego. Komendy zmieniają
stan lub żądają procesu; query odpowiadają na pytania. Ten podział zachowuje
czytelne call site'y i nie pozwala, by ogólny bus stał się jedynym mechanizmem
dependency injection w aplikacji.
Akcje, komendy, joby i eventy nie są wymienne
Użyj akcji dla operacji, której znany wywołujący potrzebuje teraz. Użyj
komendy i handlera, gdy jej komunikat oraz pipeline dispatchingu tworzą
użyteczną granicę. Użyj joba dla pracy, która może poczekać, być ponowiona
lub uruchomiona na innym workerze. Użyj eventu po fakcie (InventoryReserved), gdy niezależne listenery mogą zareagować. Nie używaj
eventu jako prośby o jedną obowiązkową operację: bez listenera cicho znika.
Większość use case'ów CRUD potrzebuje tylko akcji i Form Request. Osobny handler
komendy dla ChangeProfilePhoto daje mało, gdy jest wywołany raz, zwraca od
razu i nie ma wspólnej polityki. Nie kolejkuj też zapisu do bazy wyłącznie po to,
by był „asynchroniczny”, jeżeli następna strona potrzebuje wyniku.