You are importing a supplier's product feed. Parse the file, process forty thousand rows, then rebuild the search index and email whoever kicked it off. Some of that has to happen in a fixed order. Some of it wants to happen forty ways at once.
Laravel has a tool for each, and the useful part is knowing which shape your problem is.
A chain is a queue of one
Bus::chain runs jobs strictly one after another. The next job is not dispatched until the previous one succeeds:
Bus::chain([
new ParseFeed($upload),
new RebuildSearchIndex,
new NotifyImporter($user),
])->dispatch();
If ParseFeed fails, the other two never run. That is the point: a chain encodes a dependency, so nothing downstream sees a half-finished state.
The cost is that a chain is entirely sequential. Ten jobs of one minute each take ten minutes no matter how many workers you have idle.
A batch is the opposite trade
Bus::batch throws everything onto the queue at once and lets your workers fight over it:
Bus::batch(
$chunks->map(fn ($rows) => new ImportRows($rows))
)->name('feed import')->dispatch();
Forty thousand rows in chunks of five hundred is eighty jobs. With ten workers that finishes roughly ten times faster than doing it in sequence.
What a batch gives you that a plain loop of dispatches does not is knowing when the set is done:
Bus::batch($jobs)
->then(fn (Batch $b) => Log::info('all rows imported'))
->catch(fn (Batch $b, Throwable $e) => Log::error('import hit a failure'))
->finally(fn (Batch $b) => Storage::delete($this->path))
->dispatch();
then runs only if every job succeeded, catch on the first failure, and finally either way. That last one is where cleanup belongs.
Choosing between them
| The jobs... | Use |
|---|---|
| depend on each other's results | chain |
| must not run if an earlier step failed | chain |
| are independent units of the same work | batch |
| would finish sooner spread across workers | batch |
| need a "when everything is done" hook | batch |
If neither fits because you have both shapes in one process, you do not have to pick.
Combining them
The import above is really three phases: one job, then many jobs, then a couple of jobs. Express it exactly that way by dispatching the batch from the end of the first step, and chaining the finishing work off the batch:
class ParseFeed implements ShouldQueue
{
public function handle(): void
{
$chunks = $this->readChunks();
Bus::batch($chunks->map(fn ($rows) => new ImportRows($rows)))
->name('feed import')
->then(fn () => Bus::chain([
new RebuildSearchIndex,
new NotifyImporter($this->user),
])->dispatch())
->dispatch();
}
}
Setup runs once, the heavy work fans out across every worker you have, and the finishing steps run in order once the batch is genuinely complete. That is the shape most real pipelines want.
One bad row should not kill the import
By default a batch cancels itself the moment a job fails, and the remaining jobs are skipped. For an import where a handful of rows are malformed, that is too strict:
Bus::batch($jobs)->allowFailures()->dispatch();
Now the batch runs to completion regardless, catch still fires on the first failure, and you can inspect what went wrong afterwards:
$batch->hasFailures(); // did anything fail
$batch->failedJobs; // how many
$batch->pendingJobs; // how many still to go
Cancelling actually needs your cooperation
Calling $batch->cancel() does not stop jobs that are already queued. It marks the batch cancelled, and each job has to check before doing work. The framework ships middleware for that:
use Illuminate\Queue\Middleware\SkipIfBatchCancelled;
public function middleware(): array
{
return [new SkipIfBatchCancelled];
}
Without it, cancelling a large batch cancels nothing you can observe, which is a confusing hour to spend.
Growing a chain while it runs
A job inside a chain can add more work to the end of it, which is useful when you only discover the next step at run time:
$this->appendToChain(new ReconcileTotals($this->import));
There is a matching prependToChain() for work that must happen before the rest of the chain continues.
Wrapping up
Chains are for order and dependency, batches are for throughput and completion tracking, and most real work is a chain of phases with a batch in the middle. Reach for allowFailures() when individual failures are expected, add SkipIfBatchCancelled if you ever intend to cancel, and put cleanup in finally so it runs whichever way the batch ends.
All comments ()
No comments yet
Be the first to leave a comment on this post.