Listener sinkron menempati waktu request pengirimnya. Bila listener butuh dua detik untuk menyiapkan laporan, pelanggan menunggu dua detik lebih lama sebelum halaman checkout selesai dimuat — padahal laporan itu bukan urusan pelanggan. Kabar baiknya: menjadikan listener ber-queue di Laravel hanya butuh satu interface, karena infrastruktur antreannya sama persis dengan yang kita kenal di section Queues dan Background Jobs . Pada chapter ini kita ukur dampaknya, atur antrean dan delay, tunda push sampai transaksi database aman, lalu tangani listener yang gagal.
Satu Interface, Dampak Besar #
Listener SendOrderReport versi sinkron berisi sleep(2) sebagai pengganti pekerjaan berat. Untuk membandingkan, kita ukur dua skenario: mendaftarkan closure lambat lewat Event::listen, lalu mengirim event yang sama dengan listener versi queue:
use App\Events\OrderPlaced;
use Illuminate\Support\Facades\Event;
// Skenario A: listener sinkron yang lambat
Event::listen(OrderPlaced::class, function () {
sleep(2);
});
$a = microtime(true);
OrderPlaced::dispatch('INV-501', '[email protected]', 120000);
printf("skenario_A_dispatch_sinkron: %.2f detik\n", microtime(true) - $a);Output yang Dihasilkan:
listener-lambat selesai
skenario_A_dispatch_sinkron: 2.01 detikSekarang listener class yang mengimplementasi ShouldQueue — pesanannya justru pekerjaan yang sama:
use App\Events\OrderPlaced;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Support\Facades\Log;
class SendOrderReport implements ShouldQueue
{
public function handle(OrderPlaced $event): void
{
sleep(2); // pengganti pekerjaan berat
Log::info('laporan-order', ['orderId' => $event->orderId]);
}
}Perintah event:list menandainya secara eksplisit dengan (ShouldQueue). Ketika event terkirim, dispatcher tidak lagi memanggil handle() langsung — dispatcher membungkus listener menjadi job dan mendorongnya ke antrean. Di proses PHP terpisah kita ukur waktunya:
use App\Events\OrderPlaced;
use Illuminate\Support\Facades\DB;
DB::table('jobs')->delete();
$b = microtime(true);
OrderPlaced::dispatch('INV-502', '[email protected]', 120000);
printf("skenario_B_dispatch_queued: %.3f detik\n", microtime(true) - $b);
echo 'job_tersisa_di_antrean: ', DB::table('jobs')->count(), "\n";Output yang Dihasilkan:
skenario_B_dispatch_queued: 0.010 detik
job_tersisa_di_antrean: 1Kontrasnya tegas: dispatch sinkron 2,01 detik versus dispatch queued 0,010 detik. Tidur dua detiknya tidak hilang — pekerjaan itu hanya pindah ke antrean, dan worker akan menjalankannya seperti job biasa:
2026-10-13 20:53:26 App\Listeners\SendOrderReport .................. RUNNING
2026-10-13 20:53:28 App\Listeners\SendOrderReport .................. 2s DONEApa yang Sebenarnya Ter-serialisasi #
Job di antrean berupa baris pada tabel jobs, dan isinya bisa kita periksa langsung:
$raw = DB::table('jobs')->value('payload');
$p = json_decode($raw, true);
echo 'displayName: ', $p['displayName'], "\n";
echo 'job: ', $p['job'], "\n";
echo 'command: ', $p['data']['command'], "\n";Output yang Dihasilkan:
displayName: App\Listeners\SendOrderReport
job: Illuminate\Queue\CallQueuedHandler@call
command: O:36:"Illuminate\Events\CallQueuedListener":30:{s:5:"class";s:29:"App\Listeners\SendOrderReport";s:6:"method";s:6:"handle";s:4:"data";a:1:{i:0;O:22:"App\Events\OrderPlaced":3:{s:7:"orderId";s:7:"INV-502";s:5:"email";s:16:"[email protected]";s:5:"total";i:120000;}} ...String panjang di atas adalah hasil serialize() PHP yang dipangkas. Payload ini menjelaskan cara kerjanya: nama listener (class), method tujuan (handle), dan object event yang ikut ter-serialisasi di dalam data[0] — terlihat jelas App\Events\OrderPlaced lengkap dengan orderId, email, dan total. Saat worker mengambil job, listener di-instantiate ulang dan handle() dipanggil dengan event hasil un-serialize. Karena event dibekukan begitu dispatch terjadi, listener queued selalu menerima snapshot data saat pengiriman — perubahan data setelahnya tidak terlihat.
Antrean dan Delay per Listener #
Listener queued mewarisi seluruh pernak-pernik job. Laravel 13 menyediakan attribute untuk mengaturnya langsung di class:
use Illuminate\Queue\Attributes\Delay;
use Illuminate\Queue\Attributes\Queue;
#[Queue('reports')]
#[Delay(30)]
class SendOrderReport implements ShouldQueue
{
public function handle(OrderPlaced $event): void
{
Log::info('laporan-order', ['orderId' => $event->orderId]);
}
}Attribute #[Queue('reports')] mengarahkan job ke antrean bernama reports, sedangkan #[Delay(30)] menundanya 30 detik — berguna untuk memberi jeda, misalnya menunggu kemungkinan pembeli membatalkan pesanan sebelum laporan dibuat. Bukti keduanya terbaca dari kolom tabel jobs:
OrderPlaced::dispatch('INV-601', '[email protected]', 25000);
foreach (DB::table('jobs')->get(['queue', 'available_at', 'created_at']) as $j) {
echo 'queue: ', $j->queue, "\n";
echo 'delay_efektif: ', ($j->available_at - $j->created_at), " detik\n";
}Output yang Dihasilkan:
queue: reports
delay_efektif: 30 detikSebagai alternatif attribute, listener juga boleh mendefinisikan method viaQueue(), viaConnection(), atau withDelay() bila nilainya perlu dihitung dari isi event.
Menunggu Commit dengan afterCommit #
Ada jebakan klasik pada listener queued yang menyentuh database: event sering dikirim di dalam transaksi. Bila job dieksekusi sebelum transaksi di-commit, job membaca data yang belum ada — bahkan bisa berjalan tepat sebelum transaksi di-rollback, lalu memproses order yang sebenarnya batal. Interface ShouldQueueAfterCommit menyelesaikannya dengan menahan push job sampai transaksi yang melingkupinya commit:
class SendOrderReport implements ShouldQueue, ShouldQueueAfterCommitPerilakunya bisa kita buktikan dengan dispatch di dalam transaksi:
DB::transaction(function () {
OrderPlaced::dispatch('INV-701', '[email protected]', 15000);
echo 'job_di_antrean_saat_masih_dalam_transaksi: ', DB::table('jobs')->count(), "\n";
});
echo 'job_di_antrean_setelah_commit: ', DB::table('jobs')->count(), "\n";Output yang Dihasilkan:
job_di_antrean_saat_masih_dalam_transaksi: 0
job_di_antrean_setelah_commit: 1Selama transaksi masih hidup, antrean kosong; job baru masuk setelah commit. Di luar transaksi perilakunya normal — job langsung terkirim.
Ketika Listener Gagal #
Listener queued mengikuti seluruh aturan gagal-retry dari section queue. Contoh berikut mensimulasikan integrasi akuntansi yang selalu error, dengan $tries = 1 agar langsung gagal tanpa percobaan ulang:
class SyncToAccounting implements ShouldQueue
{
public int $tries = 1;
public function handle(InvoiceGenerated $event): void
{
throw new \RuntimeException(
'API akuntansi tidak tersedia untuk order '.$event->orderId
);
}
public function failed(InvoiceGenerated $event, \Throwable $e): void
{
Log::error('sync-akuntasi-gagal', [
'orderId' => $event->orderId,
'pesan' => $e->getMessage(),
]);
}
}Setelah InvoiceGenerated::dispatch('INV-801') dan worker dijalankan, keluarannya singkat dan jujur:
2026-10-13 20:54:52 App\Listeners\SyncToAccounting ................. RUNNING
2026-10-13 20:54:52 App\Listeners\SyncToAccounting ............. 5.11ms FAILJob gagal dipindahkan ke tabel failed_jobs dengan seluruh konteksnya — isi barisnya bisa kita periksa:
foreach (DB::table('failed_jobs')->get() as $f) {
$p = json_decode($f->payload, true);
echo 'displayName: ', $p['displayName'], "\n";
echo 'queue: ', $f->queue, "\n";
echo 'exception: ', trim(explode("\n", $f->exception)[0]), "\n";
}Output yang Dihasilkan:
displayName: App\Listeners\SyncToAccounting
queue: default
exception: RuntimeException: API akuntansi tidak tersedia untuk order INV-801 in app/Listeners/SyncToAccounting.php:15Baris exception di atas dipangkas dari path lengkapnya. Method failed() juga ikut terpanggil setelah percobaan terakhir gagal — buktinya tercatat di log:
[2026-10-13 20:54:52] local.ERROR: sync-akuntasi-gagal {"orderId":"INV-801","pesan":"API akuntansi tidak tersedia untuk order INV-801"}Perintah queue:failed menampilkan daftar job gagal untuk diinspeksi, dan queue:retry mengembalikannya ke antrean — keduanya sudah kita bahas tuntas di chapter Failed Jobs
.
Dengan listener yang bisa lambat tanpa menyandera request, pola event-driven kita kini lengkap untuk beban kerja berat. Selanjutnya kita rapikan organisasi listener yang mengelompok secara alami: satu class yang mendengarkan banyak event sekaligus.