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.

Related