Antrean yang sudah terkonfigurasi masih kosong — yang belum ada adalah pekerjaan yang layak mengisinya. Job di Laravel adalah class PHP biasa yang menampung dua hal: data yang dibutuhkan pekerjaan, dan logika pekerjaannya sendiri. Karena job dieksekusi di proses yang berbeda dari kode yang mendispatch-nya, ada satu aturan emas yang menjuntai di seluruh desainnya: data yang masuk ke job harus bisa diserialisasi. Pada chapter ini kita membuat job pertama lewat generator artisan, membedah anatomi constructor dan method handle(), lalu membuktikan bagaimana sebuah model Eloquent “menyusut” menjadi identitas kecil di dalam payload antrean.
Membuat Class Job #
Satu perintah artisan membuat kerangka job di app/Jobs:
php artisan make:job SendWelcomeEmailOutput yang Dihasilkan:
INFO Job [app/Jobs/SendWelcomeEmail.php] created successfully.File yang lahir berisi kerangka minimal yang sudah siap dipakai:
<?php
namespace App\Jobs;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
class SendWelcomeEmail implements ShouldQueue
{
use Queueable;
/**
* Create a new job instance.
*/
public function __construct()
{
//
}
/**
* Execute the job.
*/
public function handle(): void
{
//
}
}Dua baris di kerangka ini yang membedakan job dari class biasa:
implements ShouldQueue— kontrak yang memberi tahu dispatcher bahwa class ini harus didorong ke antrean. Tanpa interface ini,dispatch()mengeksekusi class secara sinkron seperti driversync.use Queueable— trait yang membawa kemampuan antrean ke class: serialisasi model, penandaonQueue()/delay(), hingga properti kontrol seperti$triesdan$backoffyang kita pakai nanti di chapter Job Retries .
Anatomi Job: constructor dan handle #
Kita isi kerangka itu menjadi job sungguhan yang mengirim email selamat datang:
<?php
namespace App\Jobs;
use App\Models\User;
use Illuminate\Contracts\Mail\Mailer;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Mail\Message;
class SendWelcomeEmail implements ShouldQueue
{
use Queueable;
public function __construct(public User $user)
{
//
}
public function handle(Mailer $mailer): void
{
$mailer->raw(
"Halo {$this->user->name}, selamat datang!",
function (Message $message) {
$message->to($this->user->email)
->subject('Selamat Datang!');
}
);
}
}Pembagian perannya tegas. Constructor menerima data — di sini model User yang dituju email — dan menyimpannya sebagai property sehingga ikut terserialisasi bersama job. Method handle() berisi aksi, dan Laravel memanggilnya lewat service container sehingga dependency bisa di-inject langsung lewat parameternya: parameter Mailer $mailer diisi otomatis tanpa kita menulis new Mailer atau memakai facade. Dependency berat seperti mailer atau HTTP client sebaiknya selalu di-inject lewat handle() — bukan disimpan di constructor — agar tidak ikut terserialisasi ke payload.
Data Serializable dan Trik ModelIdentifier #
Karena payload job disimpan sebagai teks, constructor hanya boleh menerima data yang bisa diserialisasi: scalar, array, object biasa, dan model Eloquent. Model memang object hidup, tetapi trait Queueable meringkasnya. Mari kita buktikan dengan job kedua yang menerima model Post:
use App\Models\User;
use App\Models\Post;
use App\Jobs\PublishPost;
use Illuminate\Support\Facades\DB;
$user = User::where('email', '[email protected]')->firstOrFail();
$post = Post::firstOrCreate(['title' => 'Belajar Queue Laravel'], ['user_id' => $user->id]);
PublishPost::dispatch($post);
$job = DB::table('jobs')->first();
$payload = json_decode($job->payload);
echo 'displayName: ' . $payload->displayName . "\n";
echo 'command: ' . $payload->data->command . "\n";
echo 'judul_tersimpan_di_payload: ' . var_export(
str_contains($job->payload, 'Belajar Queue Laravel'), true) . "\n";Output yang Dihasilkan:
displayName: App\Jobs\PublishPost
command: O:20:"App\Jobs\PublishPost":1:{s:4:"post";O:45:"Illuminate\Contracts\Database\ModelIdentifier":5:{s:5:"class";s:15:"App\Models\Post";s:2:"id";i:2;s:9:"relations";a:0:{}s:10:"connection";s:6:"sqlite";s:15:"collectionClass";N;}}
judul_tersimpan_di_payload: falseInilah sihir serialisasi model: judul post tidak pernah menyeberang ke payload. Yang disimpan hanyalah ModelIdentifier berisi nama class, primary key, nama connection, dan daftar relasi yang terpasang. Saat worker mengambil job, Laravel memakai identitas itu untuk mengambil ulang baris Post yang segar dari database lewat query findOrFail. Konsekuensinya, perubahan data setelah dispatch tetap terlihat oleh worker — worker selalu melihat kondisi terbaru — tapi bila barisnya sudah dihapus, job gagal diambil dan langsung jatuh ke kategori gagal.
Relasi yang terpasang pada model juga tidak ikut sebagai data; hanya namanya yang dicatat. Memuat $user->load('posts') sebelum dispatch membuat field relations berisi a:1:{i:0;s:5:"posts";} (nama relasi posts), dan payload hanya membengkak 18 byte — dari 688 menjadi 706 byte pada pengujian kita. Saat dipulihkan, relasi bernama itu di-query ulang otomatis. Justru itu sumber utama query tak terduga: setiap relasi yang terlanjur dimuat saat dispatch akan di-query ulang oleh worker, bahkan bila handle() tidak memakainya.
Job yang Benar-Benar Dikerjakan #
Payload di tabel hanya janji; worker yang menepatinya. Kita jalankan worker singkat dengan flag --stop-when-empty:
php artisan queue:work --stop-when-emptyOutput yang Dihasilkan:
2026-10-13 20:34:40 App\Jobs\SendWelcomeEmail ...................... RUNNING
2026-10-13 20:34:40 App\Jobs\SendWelcomeEmail ................. 18.38ms DONE
2026-10-13 20:34:43 Worker STOPPED Queue emptyConsole worker mencatat siklus hidup setiap job: RUNNING saat mulai, lalu DONE beserta durasinya. Karena sandbox memakai MAIL_MAILER=log, bukti eksekusinya tinggal kita baca dari berkas log:
To: [email protected]
Subject: Selamat Datang!Method handle() dengan parameter Mailer terbukti terpanggil dan email tertulis. Perilaku lengkap worker — lifecycle, prioritas antrean, hingga restart — dibedah khusus di chapter Queue Workers
.
Saat Job Gagal #
Kegagalan adalah warga kelas satu di dunia antrean. Karena model dipulihkan lewat findOrFail, baris yang hilang membuat job langsung gagal. Mari kita simulasikan: dispatch job untuk sebuah post, lalu hapus baris post itu sebelum worker sempat menjamahnya:
$post = Post::firstOrCreate(['title' => 'Post yang akan dihapus'], ['user_id' => $user->id]);
PublishPost::dispatch($post);
// baris post dihapus sebelum sempat diproses worker
$post->delete();
echo 'jobs_pending: ' . DB::table('jobs')->count() . "\n";Output yang Dihasilkan (diikuti php artisan queue:work --stop-when-empty):
jobs_pending: 1
2026-10-13 20:38:07 App\Jobs\PublishPost ........................... RUNNING
2026-10-13 20:38:07 App\Jobs\PublishPost ....................... 8.32ms FAIL
2026-10-13 20:38:10 Worker STOPPED Queue emptyBaris job berpindah rumah: dari jobs ke failed_jobs, menyimpan payload dan exception ModelNotFoundException: No query results for model [App\Models\Post]. Percobaan ulang otomatis dan penanganan failed jobs secara menyeluruh mendapat chapter tersendiri — tapi sebelum ke sana, kita perlu menguasai cara mengirim job ke antrean dengan semua variasinya: delay, antrean bernama, dan dispatch bersyarat. Semuanya ada di chapter Dispatching Jobs
. Rujukan resmi pembuatan job tersedia di dokumentasi Queues
.