Mengimpor 10.000 baris CSV lewat satu job berarti menunggu satu eksekusi raksasa selesai tanpa tahu sudah sampai mana. Job batching membalik pendekatannya: setiap baris menjadi job kecil yang berjalan paralel di worker, dan Laravel menyatukan semuanya dalam satu grup ber-ID — baris job_batches menyimpan total, sisa, dan jumlah gagal sehingga progres bisa ditampilkan sebagai persentase. Kita juga bisa menempelkan callback untuk momen seluruh batch tuntas atau sebagian gagal. Pada chapter ini kita bangun batch import sungguhan, pantau progresnya, lalu batalkan dan tambahkan job ke batch yang sedang berjalan.
Job yang Bisa Dibatching #
Job dalam batch wajib memakai trait Batchable, selain lima trait standar job. Trait inilah yang membawa properti batchId dan method batching():
namespace App\Jobs;
use Illuminate\Bus\Batchable;
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 ImportRowJob implements ShouldQueue
{
use Batchable, Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public $tries = 1;
public function __construct(public int $baris) {}
public function handle(): void
{
if (! $this->batching()) {
return;
}
if ($this->baris === 2 && ! Cache::get('import_baris2_ok')) {
throw new \RuntimeException('Baris 2: format kolom tidak valid.');
}
logger()->info("Baris {$this->baris} berhasil diimpor.");
}
}Dua baris pentingnya:
use Batchable— tanpa trait ini,Bus::batch()langsung melemparRuntimeException“it does not use the Batchable trait”.if (! $this->batching())— pemeriksaan di awalhandle()membuat job keluar diam-diam ketika batch-nya sudah dibatalkan. Framework tidak mem-skipped job otomatis; guard ini tugas kita.
Properti $tries = 1 membuat kegagalan baris 2 langsung tercatat tanpa percobaan ulang — sesuai kebutuhan demo kegagalan di bawah.
Mengirim Batch dan Membaca Progres #
Runner berikut mengirim tiga job dalam satu batch, menghentikan worker setelah satu job, lalu menyelesaikan sisanya — sehingga kita bisa melihat progres bergerak:
use App\Jobs\ImportRowJob;
use Illuminate\Bus\Batch;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\Bus;
Cache::put('import_baris2_ok', true);
$batch = Bus::batch([
new ImportRowJob(1),
new ImportRowJob(2),
new ImportRowJob(3),
])->then(function (Batch $batch) {
logger()->info('Import tuntas: '.$batch->processedJobs().' baris.');
})->finally(function (Batch $batch) {
logger()->info('Batch selesai, progress '.$batch->progress().'%.');
})->name('Import pesanan')->dispatch();
echo 'sebelum_worker: total='.$batch->totalJobs
.' pending='.$batch->pendingJobs.' progress='.$batch->progress()."%\n";
Artisan::call('queue:work', ['--stop-when-empty' => true, '--max-time' => 30, '--max-jobs' => 1]);
$batch = $batch->fresh();
echo 'tengah: processed='.$batch->processedJobs()
.' pending='.$batch->pendingJobs.' progress='.$batch->progress()."%\n";
Artisan::call('queue:work', ['--stop-when-empty' => true, '--max-time' => 30]);
$batch = $batch->fresh();
echo 'akhir: processed='.$batch->processedJobs()
.' failed='.$batch->failedJobs.' progress='.$batch->progress()."%\n";Output yang Dihasilkan:
sebelum_worker: total=3 pending=3 progress=0%
tengah: processed=1 pending=2 progress=33%
akhir: processed=3 failed=0 progress=100%Empat angka kunci batch: totalJobs dan pendingJobs adalah properti biasa, sedangkan processedJobs() dan progress() adalah method — processedJobs() dihitung dari totalJobs - pendingJobs, dan progress() membulatkannya ke persen. Method fresh() wajib dipanggil sebelum membaca ulang karena objek batch yang kita pegang hanyalah snapshot saat dispatch; angka terbarunya ada di tabel job_batches.
Callback yang tersedia pada PendingBatch:
| Callback | Dijalankan saat |
|---|---|
->then(fn) | semua job batch sukses (pending mencapai nol) |
->catch(fn) | pertama kali ada job gagal |
->finally(fn) | semua job sudah berjalan tepat sekali (sukses, gagal, atau dibatalkan) |
->allowFailures() | bukan callback, tapi mode: job berikut tetap jalan meski ada yang gagal |
Perilaku Kegagalan: Default vs allowFailures #
Dengan flag import_baris2_ok dimatikan, baris 2 pasti gagal. Batch yang sama dijalankan dua kali — sekali default, sekali dengan ->allowFailures() — menghasilkan angka final yang serupa tetapi perilaku yang berbeda:
tanpa_allowFailures: processed=2 failed=1 progress=67% cancelled=true finished=true
dengan_allowFailures: processed=2 failed=1 progress=67% cancelled=false finished=falseBedah hasilnya:
- Default — kegagalan pertama memanggil
cancel(): batch berstatuscancelled, dan callbackcatch()menerima exception-nya. Log worker membuktikan baris 3 tidak pernah dieksekusi karena guardbatching()menghentikannya. allowFailures()— baris 3 tetap dieksekusi (logBaris 3 berhasil diimpor.tetap muncul),catch()tetap terpanggil, dan batch tidak dibatalkan.- Job yang gagal tidak mengurangi
pending_jobs— karena itu progress berhenti di 67% pada kedua mode danfinished()hanya menjaditruepada batch yang dibatalkan (pembatalan otomatis menulisfinished_atjuga).
Membatalkan dan Menambah Job #
Batch yang sedang berjalan bisa dibatalkan manual dari objek batch — misalnya lewat route admin. Pemanggilan $batch->cancel() sebelum worker jalan lalu worker dijalankan menghasilkan:
cancel: cancelled=true processed=3 progress=100%
jobs_tersisa: 0Tidak ada satu pun baris yang tereksekusi (log berhasil diimpor kosong), namun progress() tetap mencapai 100% karena job yang di-skip dihitung selesai. Status pembatalan yang bisa diandalkan adalah cancelled(), bukan persentase. Operasi kebalikannya, $batch->add([...]), menyuntik job baru ke batch yang belum selesai:
$batch = Bus::batch([new ImportRowJob(1), new ImportRowJob(2)])->dispatch();
$batch->add([new ImportRowJob(3), new ImportRowJob(4)]);
$batch = $batch->fresh();
echo 'sesudah_add: total='.$batch->totalJobs.' pending='.$batch->pendingJobs."\n";Output yang Dihasilkan:
sebelum_add: total=2 pending=2
sesudah_add: total=4 pending=4
sesudah_worker: processed=4 progress=100%Batch dari Controller #
Pemicu batch paling alami adalah HTTP request — user mengunggah file, controller memecahnya menjadi job, dan response kembali seketika dengan ID batch untuk dipantau:
use App\Jobs\ImportRowJob;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Bus;
public function store(): JsonResponse
{
$batch = Bus::batch([
new ImportRowJob(1),
new ImportRowJob(2),
new ImportRowJob(3),
])->name('Import pesanan')->dispatch();
return response()->json([
'batch_id' => $batch->id,
'total' => $batch->totalJobs,
], 202);
}Route-nya cukup Route::post('/import', [ImportController::class, 'store']);. Feature test berikut mem-post endpoint, menjalankan worker sungguhan lewat queue:work --stop-when-empty, lalu memverifikasi progres di tabel job_batches:
public function test_controller_membuat_batch_dan_worker_memprosesnya(): void
{
$response = $this->postJson('/import');
$response->assertStatus(202);
$batch = Bus::findBatch($response->json('batch_id'));
fwrite(STDERR, 'progress_sebelum_worker: '.$batch->progress()."%\n");
Artisan::call('queue:work', ['--stop-when-empty' => true, '--max-time' => 30]);
$batch = Bus::findBatch($response->json('batch_id'));
fwrite(STDERR, 'progress_sesudah_worker: '.$batch->progress()."%\n");
fwrite(STDERR, 'processed_jobs: '.$batch->processedJobs()."\n");
fwrite(STDERR, 'failed_jobs: '.$batch->failedJobs."\n");
$this->assertTrue($batch->finished());
}Output yang Dihasilkan:
progress_sebelum_worker: 0%
progress_sesudah_worker: 100%
processed_jobs: 3
failed_jobs: 0
import_batch_test: lulus
PASS Tests\Feature\ImportBatchTest
✓ controller membuat batch dan worker memprosesnya 3.17sUntuk file 10.000 baris, pola yang sehat adalah Bus::batch() berisi job per-chunk (misal 500 baris per job) atau memakai LazyCollection->chunks() — bukan 10.000 job tunggal — supaya serialisasi payload tetap ramping dan worker bisa memproses paralel.
Batch menjawab kebutuhan “banyak job, satu laporan”. Untuk kebutuhan yang sebaliknya — beberapa job yang harus berjalan berurutan dengan urutan yang dijamin — Laravel punya job chains, yang kita bahas di chapter Job Chains . Dokumentasi lengkapnya ada di Queues: Job Batching .