Batches, Chains and Unique Jobs
Grouping jobs into a batch with callbacks, running them in order with a chain, deduplicating with ShouldBeUnique, and job middleware.
Bus::batch(array $jobs)->then(...)->catch(...)->finally(...)->dispatch(): Illuminate\Bus\BatchThree shapes cover most work that is more than one job.
Batches
A batch is a group of jobs tracked as one thing, with callbacks that fire when the group finishes. Jobs in it must use the Illuminate\Bus\Batchable trait. Bus::batch([...]) takes then() for all-succeeded, catch() for the first failure and finally() for either, then dispatch() returns an Illuminate\Bus\Batch whose id you can store and look up later with Bus::findBatch(). Progress lives in the job_batches table, created by php artisan make:queue-batches-table. By default one failure cancels the rest; allowFailures() lets the others continue.
Chains
Bus::chain([...]) runs jobs one after another, each dispatched only when the previous one succeeded. If any job in the chain fails, the remainder never run, and catch() on the chain is called. Use a chain for steps, a batch for work that can run in parallel.
Unique jobs
A job implementing Illuminate\Contracts\Queue\ShouldBeUnique takes a cache lock keyed on uniqueId() when dispatched; a second dispatch with the same id is dropped silently. #[UniqueFor(3600)] sets how long the lock survives, so a crashed worker cannot block the job for ever. ShouldBeUniqueUntilProcessing releases the lock when the job starts instead of when it finishes.
Job middleware
php artisan make:job-middleware RateLimited generates a class with handle(object $job, Closure $next), and a middleware() method on the job returns the instances to wrap it in. WithoutOverlapping and RateLimited ship with the framework.
Example
<?php
// app/Jobs/ImportCsvChunk.php
declare(strict_types=1);
namespace App\Jobs;
use App\Models\Import;
use Illuminate\Bus\Batchable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Queue\Attributes\Tries;
use Illuminate\Queue\Middleware\WithoutOverlapping;
#[Tries(5)]
class ImportCsvChunk implements ShouldQueue
{
use Batchable, Queueable;
public function __construct(
public int $importId,
public int $firstRow,
public int $lastRow,
) {}
/**
* Only one chunk of a given import writes at a time. A chunk that
* cannot take the lock is put back after 30 seconds, which costs
* it one of the five attempts above.
*/
public function middleware(): array
{
return [
(new WithoutOverlapping("import:{$this->importId}"))
->releaseAfter(30)
->expireAfter(180),
];
}
public function handle(): void
{
// Another chunk failed and cancelled the batch: stop.
if ($this->batch()?->cancelled()) {
return;
}
Import::findOrFail($this->importId)
->importRows($this->firstRow, $this->lastRow);
}
}When to use it
- Import a large CSV as 500-row chunks and mark the import complete when all of them finish.
- Charge, then render the PDF, then email the receipt, with nothing happening after a failed charge.
- Collapse a burst of model saves into one search-index rebuild per record.
- Stop two jobs for the same account writing to the same rows at once.
More examples
Dispatching the batch and reporting on it
The callbacks are serialised and run later in a worker, so $this is not available inside them and neither is any object that cannot be serialised. That is why $importId is copied into a local variable first rather than captured through $import. Store $batch->id and a status endpoint can later read Bus::findBatch($id) for progress(), totalJobs, pendingJobs and failedJobs.
<?php
// app/Http/Controllers/ImportController.php
declare(strict_types=1);
namespace App\Http\Controllers;
use App\Jobs\ImportCsvChunk;
use App\Models\Import;
use Illuminate\Bus\Batch;
use Illuminate\Support\Facades\Bus;
use Throwable;
class ImportController extends Controller
{
public function store(Import $import)
{
$importId = $import->id;
$batch = Bus::batch(
collect(range(1, $import->row_count))
->chunk(500)
->map(fn ($rows) => new ImportCsvChunk(
$importId,
$rows->first(),
$rows->last(),
))
->all()
)
->name("import:{$importId}")
->allowFailures()
->then(fn (Batch $batch) => Import::find($importId)
->update(['status' => 'complete']))
->catch(fn (Batch $batch, Throwable $e) => logger()->error(
'Import chunk failed', ['batch' => $batch->id]
))
->finally(fn (Batch $batch) => logger()->info(
'Import finished', ['failed' => $batch->failedJobs]
))
->dispatch();
return response()->json(['batch_id' => $batch->id]);
}
}A chain that stops at the first failure
If ChargeInvoice exhausts its attempts, the PDF and the receipt are never dispatched at all, which is what you want: no receipt for a payment that did not happen. Each job keeps its own tries and backoff, so a chain is slow to fail. Inside a chained job, prependToChain and appendToChain add further steps at run time.
<?php
use App\Jobs\GenerateInvoicePdf;
use App\Jobs\ChargeInvoice;
use App\Jobs\SendReceipt;
use Illuminate\Support\Facades\Bus;
use Throwable;
$invoiceId = $invoice->id;
Bus::chain([
new ChargeInvoice($invoice),
new GenerateInvoicePdf($invoice),
new SendReceipt($invoice),
])
->onQueue('billing')
->catch(function (Throwable $e) use ($invoiceId) {
logger()->error('Billing chain failed', ['invoice' => $invoiceId]);
})
->dispatch();
// `new ChargeInvoice($invoice)` and the parenthesis-free style the
// Laravel docs often use, `new SendReceipt`, are the same expression;
// PHP allows the parentheses to be dropped when there are no arguments.One of these, not twelve
Save a product twelve times in a minute and twelve dispatches happen, but only one job is queued: the rest find the lock held and are discarded without error. uniqueId must identify the work, not the dispatch, which is why it is the product key. UniqueFor caps how long a lost lock can block new dispatches, so pick a value comfortably above the job's worst run time.
<?php
// app/Jobs/RebuildProductIndex.php
declare(strict_types=1);
namespace App\Jobs;
use Illuminate\Contracts\Queue\ShouldBeUnique;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Queue\Attributes\UniqueFor;
use Illuminate\Queue\Middleware\Skip;
#[UniqueFor(3600)]
class RebuildProductIndex implements ShouldQueue, ShouldBeUnique
{
use Queueable;
public function __construct(
public int $productId,
public bool $force = false,
) {}
public function uniqueId(): string
{
return (string) $this->productId;
}
public function middleware(): array
{
// Shorthand: a plain boolean.
return [Skip::when($this->force === false && app()->runningUnitTests())];
// Longhand: the closure form. Skip::when() puts whatever you give
// it through value(), so the closure is called straight away, not
// deferred; both lines decide at the same moment.
// return [Skip::when(fn (): bool => $this->force === false
// && app()->runningUnitTests())];
}
public function handle(): void
{
// ...
}
}
Discussion