Job yang melempar exception tidak hilang begitu saja setelah seluruh percobaan ulangnya habis. Worker memindahkan job tersebut ke tabel failed_jobs lengkap dengan payload dan exception yang menyebabkannya gagal, sehingga kita punya jejak forensik yang bisa ditanyakan kapan saja. Dari tabel inilah kita bisa memeriksa pesan error, mengulang eksekusi secara manual, atau membersihkan entri lama agar tabel tidak membengkak. Pada chapter ini kita jalankan satu siklus gagal–retry–berhasil secara nyata, lalu kelola tabelnya dengan perintah artisan.

Mekanisme Tabel failed_jobs #

Saat $tries job sudah tercapai dan job masih melempar exception, worker memanggil queue.failer (default driver database-uuids di config/queue.php) yang menulis satu baris ke failed_jobs. Skema tabelnya sudah dibawa migrasi bawaan 0001_01_01_000002_create_jobs_table.php:

KolomIsi
uuididentitas job — sama dengan UUID baris di tabel jobs, dipakai sebagai ID oleh queue:failed
connectionnama koneksi antrean, misal database
queuenama antrean, misal default
payloadjob terserialisasi (class, properti, displayName)
exceptionpesan dan stack trace exception yang dilempar
failed_atwaktu kegagalan

Karena driver database-uuids memetakan kolom uuid sebagai ID, semua perintah pengelolaan (queue:retry, queue:forget) menerima UUID — bukan angka auto-increment. Tabel jobs sendiri tidak menyimpan apa pun tentang kegagalan; pengetahuan itu hanya ada di failed_jobs.

Job Demo: Gagal Terkendali #

Kita pakai job pembayaran yang gagalnya bisa dikendalikan lewat sebuah flag di cache, supaya siklus gagal lalu sukses bisa direproduksi:

php
namespace App\Jobs;

use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Cache;

class FlakyPaymentJob implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

    public $tries = 2;

    public function __construct(public string $orderId) {}

    public function handle(): void
    {
        if (! Cache::get('payment_gateway_ok')) {
            throw new \RuntimeException(
                'Payment gateway timeout untuk pesanan '.$this->orderId
            );
        }

        logger()->info("Pembayaran pesanan {$this->orderId} berhasil diproses.");
    }
}

Properti $tries = 2 berarti job hanya boleh mencoba dua kali; pada percobaan kedua yang gagal, worker tidak merilis job kembali ke antrean melainkan langsung mencatatnya ke failed_jobs. Selama flag payment_gateway_ok belum ada di cache, method handle() melempar RuntimeException — meniru payment gateway yang sedang down.

Siklusnya dimulai dengan dispatch dan satu putaran worker:

bash
php artisan tinker --execute="dispatch(new App\Jobs\FlakyPaymentJob('INV-881'));"
php artisan queue:work --stop-when-empty

Output yang Dihasilkan:

text
  2026-10-13 20:18:38 App\Jobs\FlakyPaymentJob ....................... RUNNING
  2026-10-13 20:18:38 App\Jobs\FlakyPaymentJob ................... 2.65ms FAIL
  2026-10-13 20:18:38 App\Jobs\FlakyPaymentJob ....................... RUNNING
  2026-10-13 20:18:38 App\Jobs\FlakyPaymentJob ................... 0.95ms FAIL
  2026-10-13 20:18:41 Worker STOPPED Queue empty

Job berjalan dua kali (sesuai $tries), dua-duanya FAIL, lalu worker berhenti karena antrean kosong. Perintah queue:failed membuktikan job kini tinggal di tabel gagal:

bash
php artisan queue:failed

Output yang Dihasilkan:

text
  2026-10-13 20:18:38 847bf4f8-49ad-44c0-a44f-499181d0f9ed database@default App\Jobs\FlakyPaymentJob

Format tiap baris: waktu gagal, UUID, connection@queue, lalu nama class job. UUID di kolom kedua inilah handle yang kita pakai untuk retry.

Retry: Mengembalikan Job ke Antrean #

queue:retry mengambil baris dari failed_jobs, mereset hitungan percobaan, dan mendorong payload yang sama kembali ke tabel jobs. Kita perbaiki dulu akar masalahnya — nyalakan flag gateway — lalu retry:

bash
php artisan tinker --execute="Cache::put('payment_gateway_ok', true);"
php artisan queue:retry 847bf4f8-49ad-44c0-a44f-499181d0f9ed

Output yang Dihasilkan:

text
  INFO  Pushing failed queue jobs back onto the queue.

  847bf4f8-49ad-44c0-a44f-499181d0f9ed ........................... 2.17ms DONE

Satu putaran worker lagi menjalankan job yang sama persis, dan kali ini ia sukses:

bash
php artisan queue:work --stop-when-empty

Output yang Dihasilkan:

text
  2026-10-13 20:19:08 App\Jobs\FlakyPaymentJob ....................... RUNNING
  2026-10-13 20:19:08 App\Jobs\FlakyPaymentJob ................... 4.98ms DONE
  2026-10-13 20:19:11 Worker STOPPED Queue empty

Baris log Pembayaran pesanan INV-881 berhasil diproses. tercatat di storage/logs/laravel.log, dan queue:failed kini melaporkan No failed jobs found. karena barisnya dihapus begitu job dikembalikan ke antrean. Argumen all mengulang seluruh job gagal sekaligus: php artisan queue:retry all.

Bereaksi lewat Event JobFailed #

Untuk merespons kegagalan secara programatik (kirim notifikasi ke tim, catat ke sentry), kita bisa mendaftarkan listener JobFailed. Runner berikut mendaftarkan callback Queue::failing(), mendispatch job dengan flag gateway dimatikan, lalu menjalankan worker di proses yang sama:

php
use App\Jobs\FlakyPaymentJob;
use Illuminate\Queue\Events\JobFailed;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Queue;

Queue::failing(function (JobFailed $event) {
    echo 'job: '.$event->job->resolveName()."\n";
    echo 'pesan: '.$event->exception->getMessage()."\n";
    echo 'attempts: '.$event->job->attempts()."\n";
});

dispatch(new FlakyPaymentJob('INV-910'));
Artisan::call('queue:work', ['--stop-when-empty' => true, '--max-time' => 20]);

echo 'failed_jobs_baris: '.DB::table('failed_jobs')->count()."\n";

Output yang Dihasilkan:

text
job: App\Jobs\FlakyPaymentJob
pesan: Payment gateway timeout untuk pesanan INV-910
attempts: 2
failed_jobs_baris: 1

Objek event membawa job ($event->job) dan exception aslinya ($event->exception), jadi listener tahu persis apa yang gagal dan kenapa. Tempat paling lazim untuk registrasi ini adalah method boot() di AppServiceProvider karena callback berlaku untuk semua worker yang berjalan.

Membersihkan Tabel dengan forget, flush, dan prune #

PerintahEfek
queue:forget <uuid>hapus satu baris job gagal
queue:flushhapus semua baris job gagal
queue:flush --hours=Nhapus yang gagal lebih dari N jam lalu
queue:prune-failed --hours=Npertahankan N jam terakhir, hapus sisanya

Uji cepat queue:forget pada job gagal lain:

bash
php artisan queue:forget fd804b81-82df-4400-a24d-c08ee2d49acd

Output yang Dihasilkan:

text
  INFO  Failed job deleted successfully.

Untuk pembersihan rutin, queue:prune-failed --hours=0 menghapus semua entri dan melaporkan jumlahnya:

bash
php artisan queue:prune-failed --hours=0

Output yang Dihasilkan:

text
  INFO  1 entries deleted.

Dengan kegagalan kini terkelola — tercatat, bisa diulang, bisa dibersihkan — kita siap membahas pola yang menjalankan banyak job sekaligus dalam satu grup terpantau: Job Batching . Rincian lengkap mekanisme failed jobs tersedia di dokumentasi Queues .