Job Batching
Some operations fan out: a multi-create form that submits forty rows, a bulk import that reads a spreadsheet, a multi-file export. Each row is independent work, and the user wants to watch it finish — a progress bar, a count of what succeeded and failed, and a Cancel-All button. That shape is what Laravel's job batching exists for, and the project wraps it through the Core\JobBatch feature group.
The rule for this chapter is mostly about restraint. Native Bus::batch() already tracks totals, processed counts, failures, progress percentage, and cancellation. The mistake is re-implementing any of that — a custom *_batches table, a Cache entry for state, an Eloquent accessor that recomputes progress(). Don't. Every count and status comes from the native Illuminate\Bus\Batch object, and nothing is cached or shadowed.
The shape
Producer.store() → Producer Action → Bus::batch([Job, ...])
->withOption('user_id', $uid)
->withOption('user_type', $type)
->dispatch()
→ return $batch->id (JSON)
Frontend polls ──► JobBatchController.show($id) ──► Bus::findBatch($id) + failed_jobs lookup
Frontend cancels ──► JobBatchController.cancelAll($id) ──► $batch->cancel()
Three rules hold the pattern together:
- One Job class per unit of work — never a closure inside
Bus::batch(). A real Job class is easier to retry, monitor on the queue, and unit-test. - Ownership lives in
withOption('user_id', …)— it is written tojob_batches.optionsand survives worker boundaries. A session-storedbatch_iddoes not. - Every count, percentage, and status comes from the native
Batchobject —totalJobs,processedJobs(),failedJobs,progress(),finished(),cancelled(),cancel(). Nothing is recomputed in an accessor; nothing is cached.
Why native only
The native Batch object exposes everything a batch-level UI needs:
| Need | Native API |
|---|---|
| Total | $batch->totalJobs |
| Processed | $batch->processedJobs() |
| Failed (count) | $batch->failedJobs |
| Failed (UUIDs) | $batch->failedJobIds → join failed_jobs.uuid for the messages |
| Progress % | $batch->progress() |
| Finished? | $batch->finished() |
| Cancelled? | $batch->cancelled() |
| Cancel the batch | $batch->cancel() |
| Ownership / metadata | $batch->options (from withOption()) |
What native batching does not give you is per-row state (pending | processing | completed | failed) or per-row cancellation.
Where the code lives
JobBatch is a feature group inside the Core domain, not a separate domain. The files sit in Core's existing buckets, under a JobBatch/ sub-folder where there is more than one file (the same convention as Core/Actions/StateMachine/ — see State machine).
src/Domain/Core/
├── Actions/
│ └── JobBatch/
│ ├── GetJobBatch.php // Bus::findBatch($id)
│ ├── CancelJobBatch.php // $batch->cancel()
│ └── GetFirstFailureMessage.php // failed_jobs.exception lookup
├── Contracts/
│ └── JobBatchContract.php
├── Providers/
│ └── JobBatchServiceProvider.php // registered in bootstrap/providers.php
└── Services/
└── JobBatchService.php // pass-through over the three Actions
The Contract has three methods and no more:
interface JobBatchContract
{
public function getJobBatch(string $id): ?Batch; // Bus::findBatch wrapper
public function cancel(Batch $batch): void; // delegates to $batch->cancel()
public function firstFailureMessage(array $failedJobIds): ?string; // failed_jobs.exception, first line
}
There is no Eloquent JobBatch model in the read path, no listing Action, no FilterJobBatchData — there is no admin listing page. Every batch is reached by ID from the producer's own polling UI. If you ever need a list, add those pieces back at that point, not before.
The producer side
The producer Action stays slim: build the array of Job instances, dispatch with ownership options, return the batch ID.
// Producer Action — build the Job array, dispatch with ownership options.
public function handle(): string
{
$jobs = $rows
->map(fn ($row) => new CreateOrdersFromExcelRow($row, $defaults))
->all();
$batch = Bus::batch($jobs)
->name('create-orders-excel')
->withOption('user_id', $this->data->created_by_id)
->withOption('user_type', $this->data->created_by_type)
->dispatch();
return $batch->id;
}
Each Job guards against a cancelled batch and otherwise does its one unit of work. It writes no batch state of its own.
// Producer Job — guard cancellation, then work.
public function handle(): void
{
if ($this->batch()?->cancelled()) {
return;
}
CreateOrdersExcel::processRow($this->row, $this->defaults);
}
The cancellation guard matters: cancelling a batch stops new jobs from being released, but jobs already on the queue still run. Without the $this->batch()?->cancelled() check at the top of handle(), every remaining job keeps writing its side effects after the user hit Cancel.
The batch controller
Polling and cancellation are owned by one shared controller — JobBatchController — never re-implemented on each producer.
| Method | HTTP | Returns | Job |
|---|---|---|---|
show | GET /cp/job_batches/{id} | JSON | status + counters + first error, for the polling UI |
cancelAll | POST /cp/job_batches/{id}/cancel | JSON | $batch->cancel() |
The route group is excluded from role/permission scaffolding in config/roles.php (cp.job_batches.* lives in cp.exclude). Ownership is enforced per request from job_batches.options, not from the session:
public function show(string $batchId): JsonResponse
{
$batch = $this->findOwnedBatch($batchId);
return response()->json([
'status' => $this->statusOf($batch),
'total' => $batch->totalJobs,
'processed' => $batch->processedJobs(),
'failed' => $batch->failedJobs,
'progress' => $batch->progress(),
'error' => $this->firstFailureMessage($batch->failedJobIds),
]);
}
private function findOwnedBatch(string $batchId): Batch
{
$batch = $this->jobBatchContract->getJobBatch($batchId);
abort_if($batch === null, 404);
abort_unless(
($batch->options['user_id'] ?? null) === (string) auth('employees')->id()
&& ($batch->options['user_type'] ?? null) === Employee::class,
403,
);
return $batch;
}
private function statusOf(Batch $batch): string
{
return match (true) {
$batch->cancelled() => 'cancelled',
$batch->finished() => $batch->failedJobs > 0 ? 'failed' : 'completed',
default => 'processing',
};
}
The frontend
The producer view polls the two endpoints — it does not talk to the queue directly:
BATCH_URL_BASE = "{{ url('cp/job_batches') }}"
GET BATCH_URL_BASE + '/' + id → poll { status, total, processed, failed, progress, error }
POST BATCH_URL_BASE + '/' + id + '/cancel' → cancel-all
The UI shows a big percentage, the native progress bar, three counters (Created = processed − failed, Failed, Total), and a single Cancel-All button. On finish it swaps in a "Go to orders" call-to-action. There is no per-row list, no per-row cancel button, and no per-row status badge — that is the per-row state the pattern deliberately doesn't track.
Adding a new batched feature
- The producer Action accepts a DTO and builds
new YourJob($payload, $defaults)per unit of work. - The Job's
handle()early-returns on$this->batch()?->cancelled(), then does the work. - Dispatch with
Bus::batch($jobs)->withOption('user_id', …)->withOption('user_type', …)->dispatch(). - The producer Action returns
$batch->id; the producer controller'sstore()returns it as{ batch_id }JSON. - Reuse
JobBatchControllerfor polling and cancel-all — do not addstatus/cancelendpoints to the producer controller.
When not to use it
- One job, fire-and-forget —
dispatch(new Job(...)). A batch is pure overhead for a single job. - Strict sequential dependency —
Bus::chain([...]). Batches run their jobs in parallel; a chain runs them in order. - No UI feedback needed — if nothing polls the batch, you are paying for a
job_batchesrow no one reads.
What to read next
- Cross-module communication — events and queued jobs as the sanctioned ways one domain triggers work in another.
- Actions — the producer Action and the per-row work both follow the standard Action template.
- State machine — the other
Corefeature group that uses the sameActions/{Feature}/sub-folder convention. - Anti-patterns — the project's review vocabulary; the "don't shadow native state" rule here is the same instinct.