Різниця перш за все семантична. Event описує факт, який уже стався: OrderPaid, UserRegistered. Він не знає, хто на нього відреагує, і скільки буде слухачів. Listener підписується на подію й виконує реакцію. Job — це команда: конкретна одиниця роботи, яку хтось явно поставив у чергу, наприклад GenerateInvoicePdf.
Технічно межа розмита. Listener, який реалізує ShouldQueue, стає окремим job у черзі, зі своїми tries, backoff і failed(). Тому питання «job чи listener» зводиться до того, чи потрібна розвʼязка через подію. Якщо на факт реагує кілька незалежних дій, подія дає їм незалежні спроби й незалежні падіння. Якщо потрібна одна важка операція з ланцюжком або батчем, це job.
Все, що йде в чергу, виконується щонайменше один раз. Retry гарантований, воркер може впасти після виконання дії, але до підтвердження. Тому обробник мусить бути ідемпотентним: перевіряти стан агрегату перед дією, а не вірити, що його викликали вперше.
Друга типова пастка — транзакції. Подія, відправлена всередині DB::transaction, може дістатись воркера раніше, ніж транзакція закомітилась, і слухач не знайде запис. Рішення: ShouldDispatchAfterCommit на події, afterCommit() на job або глобальна опція after_commit у конфігурації черги.
// Подія: факт у минулому часі, без знання про споживачів
final class OrderPaid implements ShouldDispatchAfterCommit
{
public function __construct(public readonly int $orderId) {}
}
// Слухач у черзі: окремий job на кожен listener, незалежні падіння
final class SendReceipt implements ShouldQueue
{
public int $tries = 3;
public array $backoff = [10, 60, 300];
public function handle(OrderPaid $event): void
{
$order = Order::findOrFail($event->orderId);
if ($order->receipt_sent_at !== null) {
return; // ідемпотентність: повтор не шле другий лист
}
Mail::to($order->email)->send(new ReceiptMail($order));
$order->update(['receipt_sent_at' => now()]);
}
public function failed(OrderPaid $event, Throwable $e): void
{
Log::error('Receipt failed', ['order' => $event->orderId, 'error' => $e->getMessage()]);
}
}
// Job: явна команда зробити конкретну роботу
GenerateInvoicePdf::dispatch($order->id)->onQueue('pdf')->afterCommit();
DB::transaction(function () use ($order) {
$order->markPaid();
OrderPaid::dispatch($order->id); // піде після коміту
});
Скажіть, що ключове питання — ідемпотентність: усе, що йде в чергу, мусить безпечно виконуватись повторно після retry. І згадайте afterCommit для подій усередині транзакцій.