Job Laravel Gagal Sebelum handle() Jalan, dan Retry Tidak Akan Menolong
- Authors

- Name
- Samsul Hadi
- Threads
- @Threads


Bayangkan ini terjadi setelah deploy sore hari. Besok paginya ada laporan dari pengguna: tombol download laporan ditekan, statusnya tidak pernah selesai. Kamu buka Horizon, dan job-nya memang gagal. Yang aneh, waktu dikirim dan waktu gagalnya sama persis, sampai ke detiknya. Percobaan pertama langsung gagal, dan retry berikutnya juga gagal dengan pesan yang sama.
Ini kejadian nyata di salah satu proyek klien yang saya pegang, sebuah aplikasi pelaporan berbasis Laravel. Pesan di Horizon kurang lebih begini:
The script tried to call a method on an incomplete object.
Please ensure that the class definition "...ProcessDownloadExecutiveReportJob"
... was loaded _before_ unserialize() gets called
Job itu tidak pernah sempat menjalankan satu baris pun dari handle(). Ia gagal saat Laravel mencoba membuka isi payload-nya. Dan selama isi payload itu sama, mau dicoba ulang berapa kali pun hasilnya tetap gagal.
Artikel ini membahas penyebabnya sampai ke mekanismenya, lalu cara memperbaikinya langkah demi langkah. Semua angka dan screenshot di bawah berasal dari demo Laravel 12 yang saya jalankan sendiri untuk mereproduksi kasus ini, plus angka asli dari commit perbaikan di proyek tersebut.
Kenapa job bisa gagal sebelum handle()
Saat kamu menulis DownloadReport::dispatch($a, $b, $c), Laravel tidak mengirim "perintah". Ia menyimpan seluruh objek job, termasuk semua properti dari constructor, dalam bentuk serialize() PHP. Hasilnya ditulis ke Redis atau tabel jobs. Worker kemudian mengambil teks itu dan menjalankan unserialize() untuk menghidupkan kembali objek job, baru setelah itu memanggil handle().
Masalahnya ada di langkah membuka itu. unserialize() hanya berhasil kalau setiap class yang tercatat di dalam payload bisa dimuat oleh worker. Kalau ada satu class yang namanya berubah, dipindah namespace saat refactor, atau berasal dari library yang versinya sudah beda, PHP mengubah objek itu menjadi __PHP_Incomplete_Class. Job gagal di sana, sebelum handle().
Itu sebabnya retry tidak menolong. Retry mengulang hal yang sama: membuka payload yang sama, dengan class yang sama-sama tidak ada. Retry hanya berguna untuk kegagalan yang bisa berubah, seperti koneksi putus. Kegagalan karena isi payload tidak akan pernah berubah dengan sendirinya.
Ada satu detail yang jarang disadari. Trait SerializesModels, yang otomatis ikut lewat Queueable di Laravel 11 ke atas, memang mengubah model Eloquent menjadi ModelIdentifier yang ringan: hanya nama class, id, dan nama relasi. Tapi itu hanya berlaku untuk model yang menjadi properti utama job. Model yang diselipkan di dalam array atau di dalam objek lain ikut terserialisasi utuh, lengkap dengan semua relasinya.
Di demo, saya mengirim satu laporan dengan 40 bagian. Versi job yang membawa model di dalam array ternyata menyimpan 40 objek ReportSection utuh di payload-nya. Versi yang membawa model sebagai properti utama hanya menyimpan satu ModelIdentifier.
Prinsipnya: job membawa rujukan, bukan bahan jadi
Di proyek klien tadi, ketiga job download menerima bahan yang sudah dibangun sebelum dispatch. Isinya objek dokumen PhpWord yang hidup, model Eloquent yang sudah terhidrasi penuh dengan relasi bersarang, dan sebelas ringkasan biaya. Job lampiran bahkan menerima enam belas argumen constructor.
Semua bahan itu ikut masuk Redis. Makin banyak class yang menumpang di satu payload, makin besar peluang salah satunya gagal dimuat setelah ada perubahan kode.
Perbaikannya sederhana di atas kertas: bahan-bahan itu tidak perlu lewat antrean sama sekali. Semuanya bisa dihitung ulang dari id laporan. Jadi job cukup membawa dua hal, yaitu model laporan dan id pengguna, lalu membangun sendiri bahannya di dalam handle().
Hasil pengukuran di proyek itu, untuk laporan yang sama:
| Job | Sebelum | Sesudah |
|---|---|---|
| Laporan eksekutif | 60,2 KB | 293 byte |
| Lampiran | 547,5 KB | 293 byte |
Ada bonus yang tidak kalah penting. Dengan cara lama, bahan dihitung saat tombol ditekan, lalu menunggu di antrean. Kalau data berubah selama menunggu, hasil download memakai data basi. Dengan cara baru, bahan dihitung saat job benar-benar berjalan.
Satu keputusan yang sengaja saya ambil: rumus penilaian yang membangun isi laporan, sekitar 666 baris, tidak dipindahkan. Yang diubah hanya visibilitasnya dari private menjadi public, lalu dibungkus satu class tipis bernama ReportPayload. Memindahkan rumus penilaian jauh lebih berisiko daripada masalah yang sedang diperbaiki, dan layak punya branch serta tesnya sendiri. Dengan class tipis itu, kalau suatu hari rumusnya dipindah, cukup satu class yang berubah, bukan ketiga job.
Mengecilkan payload job
Demo ini memakai Laravel 12 dengan queue driver database dan SQLite, jadi bisa kamu jalankan di laptop tanpa Redis. Mekanismenya sama di Redis.
Langkah 1: ukur payload job kamu sekarang
Sebelum mengubah apa pun, ukur dulu. Buat satu command kecil yang men-dispatch job lalu membaca panjang kolom payload di tabel jobs:
Artisan::command('demo:dispatch', function () {
$report = Report::with('sections')->first();
// Versi gemuk: semua bahan dibangun sekarang dan ikut dikirim.
DownloadReportFat::dispatch(
ReportPayload::document($report),
['report' => $report, 'summaries' => ReportPayload::summaries($report)],
1,
);
// Versi ramping: hanya rujukan.
DownloadReportThin::dispatch($report, 1);
foreach (DB::table('jobs')->orderBy('id')->get() as $job) {
$name = json_decode($job->payload)->displayName;
$this->line(sprintf('%-32s %10s bytes', $name, number_format(strlen($job->payload))));
}
});

Untuk laporan yang sama, job gemuk berukuran 234.967 byte, job ramping 673 byte.
Langkah 2: lihat apa yang sebenarnya ikut terbawa
Angka saja belum cukup. Lihat isinya. Hitung berapa model yang terbawa utuh dan berapa yang hanya berupa ModelIdentifier:
$command = json_decode($job->payload)->data->command;
substr_count($command, 'ModelIdentifier'); // model sebagai rujukan
substr_count($command, 'O:24:"App\Models\ReportSection"'); // model yang terbawa utuh
Di demo, job gemuk membawa 40 objek ReportSection utuh dan nol ModelIdentifier. Job ramping sebaliknya. Kalau kamu menemukan model utuh di payload, hampir pasti ada model yang terselip di dalam array atau objek lain.
Seperti ini isi job ramping setelah di-serialize. Perhatikan bahwa relasi sections hanya dicatat namanya, lalu dimuat ulang oleh worker:

Langkah 3: reproduksi kegagalannya
Supaya yakin penyebabnya benar, reproduksi dulu. Dispatch kedua job, lalu simulasikan deploy dengan memindahkan class ReportDocument ke namespace lain sebelum worker berjalan. Ini persis yang terjadi ketika refactor ter-deploy sementara masih ada job di antrean.
php artisan demo:dispatch
# simulasi deploy: App\Support\ReportDocument dipindah ke App\Documents\ReportDocument
composer dump-autoload
php artisan queue:work --stop-when-empty --tries=3

Job gemuk gagal tiga kali berturut-turut. Job ramping selesai normal, karena payload-nya tidak membawa class yang berubah. Isi failed_jobs menunjukkan di mana ia gagal:

Kegagalan terjadi di SerializesModels, saat payload dibuka. Pesannya sedikit berbeda dengan yang muncul di Horizon proyek klien karena di demo propertinya bertipe, tapi akarnya sama: __PHP_Incomplete_Class.
Langkah 4: ubah job agar hanya membawa rujukan
Sebelum:
class DownloadReportFat implements ShouldQueue
{
use Queueable;
public function __construct(
public ReportDocument $document,
public array $context, // ['report' => Report, 'summaries' => [...]]
public int $userId,
) {}
}
Sesudah:
class DownloadReportThin implements ShouldQueue
{
use Queueable;
public function __construct(
public Report $report,
public int $userId,
) {}
public function handle(): void
{
$report = $this->report->load('sections');
ReportPayload::document($report)->save(storage_path("app/report-{$report->id}.txt"));
}
}
Aturannya: properti job hanya boleh berisi model sebagai properti utama, atau nilai skalar seperti id, string pendek, dan angka. Jangan objek library, jangan array berisi model.
Langkah 5: pindahkan pembangunan bahan ke satu class tipis
class ReportPayload
{
public static function document(Report $report): ReportDocument
{
$document = new ReportDocument();
foreach ($report->sections as $section) {
$document->addParagraph($section->heading."\n".$section->body);
}
return $document;
}
}
Class ini sengaja tidak berisi logika baru. Ia hanya memanggil kode yang sudah ada. Tujuannya satu: job tidak perlu tahu cara membangun bahannya.
Langkah 6: pasang tes penjaga, dan hindari jebakan sync
Perbaikan seperti ini gampang kembali rusak. Satu orang menambahkan argumen ke constructor "biar praktis", dan payload membengkak lagi. Jadi pasang tes yang menjaga bentuknya.
Ada jebakan yang saya temui sendiri saat menulis tes ini. phpunit.xml bawaan Laravel memakai QUEUE_CONNECTION=sync. Dengan driver sync, job langsung dijalankan dan tidak pernah masuk tabel jobs. Tes ukuran payload saya awalnya "lulus" karena panjang payload yang kosong dihitung nol. Solusinya, paksa tes memakai antrean database dan pastikan payload benar-benar ada:
protected function setUp(): void
{
parent::setUp();
config(['queue.default' => 'database']);
}
private function payload(): string
{
$payload = DB::table('jobs')->value('payload');
$this->assertNotNull($payload, 'job was not queued');
return $payload;
}
public function test_payload_stays_under_two_kilobytes(): void
{
$this->seed(DemoReportSeeder::class);
DownloadReportThin::dispatch(Report::with('sections')->first(), 1);
$this->assertLessThan(2048, strlen($this->payload()));
}

Di proyek klien, ada dua belas tes baru dengan tujuan yang sama: constructor hanya menerima dua argumen, payload tetap di bawah 2 KB, payload bisa dibuka kembali, dan properti berat tidak masuk lagi.
Langkah 7: buktikan hasilnya tidak berubah
Refactor yang mengubah cara bahan dibangun wajib dibuktikan tidak mengubah hasil. Di proyek klien, saya memakai command karakterisasi yang merekam output laporan di kode lama, lalu membandingkannya dengan kode baru:
reporting:characterize --limit=12
No drift. All 12 reportings produce what they produced before.
Setelah itu job eksekutif dijalankan ujung ke ujung pada laporan yang sebelumnya gagal di Horizon, dan file-nya berhasil dibuat. Seluruh 603 tes lulus.
Checklist sebelum kamu merge job baru
- Constructor job hanya menerima model sebagai properti utama, plus nilai skalar.
- Tidak ada model Eloquent di dalam array atau objek lain yang dikirim ke job.
- Tidak ada objek library (dokumen, PDF, client HTTP, koleksi besar) di properti job.
- Ukuran payload diukur, dan ada tes yang menjaganya tetap kecil.
- Tes payload berjalan di driver
databaseatauredis, bukansync. - Bahan dibangun di dalam
handle(), dari data saat job berjalan. - Ada cara membuktikan hasil sebelum dan sesudah refactor sama, misalnya command karakterisasi.
Sebelum kamu menyalin demo ini
Prinsip ini bukan obat semua masalah antrean.
- Kalau datanya memang harus dibekukan saat tombol ditekan, misalnya harga pada saat pembeli checkout, jangan andalkan perhitungan ulang. Simpan snapshot-nya ke database, lalu kirim id snapshot itu ke job.
- Model yang sudah dihapus akan membuat job gagal saat worker memuat ulang model tersebut. Laravel punya opsi
$deleteWhenMissingModelskalau perilaku itu memang yang kamu mau. - Relasi dimuat ulang di worker. Itu berarti ada query tambahan saat job berjalan. Untuk job yang berjalan ribuan kali per menit, ukur dampaknya.
- Demo memakai driver database, bukan Redis dan Horizon. Mekanisme serialisasinya sama, tapi pesan error bisa sedikit berbeda, seperti yang terlihat di langkah 3.
Untuk kode antrean warisan, angka payload dari langkah 1 biasanya sudah cukup menunjukkan job mana yang perlu dibenahi lebih dulu. Bagian kedua seri ini membahas job yang statusnya tidak pernah selesai.
Artikel terkait
Tulisan lain yang nyambung dengan topik ini.


Tiga Hal yang Langsung Saya Terapkan Setelah Paham Kenapa Laravel Queues Itu Bukan Fitur Opsional
Selama ini saya anggap queue itu fitur advanced. Ternyata itu basic. Ini yang berubah setelah saya benar-benar memahaminya.


Retry yang Aman Dimulai dari State, Bukan Timeout
Retry yang aman tidak dimulai dari berapa kali proses boleh diulang, tetapi dari state, identitas operasi, dan cara memverifikasi hasil.


The Best Programming Languages for a High-Paying Tech Career in 2025
We rank the top programming languages that can boost your earnings and job prospects in 2025.
Comments
Komentar akan tampil setelah disetujui.