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:

php
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:

text
listener-lambat selesai
skenario_A_dispatch_sinkron: 2.01 detik

Sekarang listener class yang mengimplementasi ShouldQueue — pesanannya justru pekerjaan yang sama:

php
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:

php
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:

text
skenario_B_dispatch_queued: 0.010 detik
job_tersisa_di_antrean: 1

Kontrasnya 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:

text
  2026-10-13 20:53:26 App\Listeners\SendOrderReport .................. RUNNING
  2026-10-13 20:53:28 App\Listeners\SendOrderReport .................. 2s DONE

Apa yang Sebenarnya Ter-serialisasi #

Job di antrean berupa baris pada tabel jobs, dan isinya bisa kita periksa langsung:

php
$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:

text
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:

php
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:

php
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:

text
queue: reports
delay_efektif: 30 detik

Sebagai alternatif attribute, listener juga boleh mendefinisikan method viaQueue(), viaConnection(), atau withDelay() bila nilainya perlu dihitung dari isi event.

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:

php
class SendOrderReport implements ShouldQueue, ShouldQueueAfterCommit

Perilakunya bisa kita buktikan dengan dispatch di dalam transaksi:

php
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:

text
job_di_antrean_saat_masih_dalam_transaksi: 0
job_di_antrean_setelah_commit: 1

Selama 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:

php
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:

text
  2026-10-13 20:54:52 App\Listeners\SyncToAccounting ................. RUNNING
  2026-10-13 20:54:52 App\Listeners\SyncToAccounting ............. 5.11ms FAIL

Job gagal dipindahkan ke tabel failed_jobs dengan seluruh konteksnya — isi barisnya bisa kita periksa:

php
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:

text
displayName: App\Listeners\SyncToAccounting
queue: default
exception: RuntimeException: API akuntansi tidak tersedia untuk order INV-801 in app/Listeners/SyncToAccounting.php:15

Baris exception di atas dipangkas dari path lengkapnya. Method failed() juga ikut terpanggil setelah percobaan terakhir gagal — buktinya tercatat di log:

text
[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.