Profile    Mohammed Shiroz Status   Loading  
Logo
Share This
Back to blog
Filter by:
Tags
//Article title

Laravel Queues in Depth: Chains, Batches and Unique Jobs

About Post

Dispatching one job is the easy part. SendWelcomeEmail::dispatch($user), a worker picks it up, done.

Real background work is rarely one job. It's "generate the contract PDF, then send it for signature, then tell the leasing team". It's "build a statement for every tenant this month and tell me when they're all done". It's "the user clicked Recalculate four times, please don't run it four times". And it's "the SMS provider only accepts so many messages a minute".

Laravel has a tool for each of those, and they're some of the most underused parts of the framework. Let's go through them with the gotchas that only show up once real traffic arrives.

Chains: steps that must happen in order

A chain runs jobs one after another. The next job only starts when the previous one succeeded. If any job fails (after its retries), the rest of the chain is abandoned.

use Illuminate\Support\Facades\Bus;

Bus::chain([
    new GenerateContractPdf($contract),
    new SendForSignature($contract),
    new NotifyLeasingTeam($contract),
])->catch(function (Throwable $e) {
    report($e);
})->onQueue('contracts')->dispatch();

Why not just call all three steps inside one big job? Because each step can now retry on its own. If the e-signing provider has a bad minute, only SendForSignature retries. The PDF isn't generated again, and the team isn't notified twice.

The thing that surprises people: jobs in a chain don't pass return values to each other. There's no "output of step one becomes input of step two". Each job reads what it needs from the database. So GenerateContractPdf stores the file path on the contract, and SendForSignature reads it from there. That feels clumsy at first, but it's actually what you want: the state survives a crash, and you can see exactly how far a chain got.

Batches: many jobs, one finish line

A batch is the opposite shape. The jobs are independent and can run in parallel across workers, but you care about the group: how far along it is, and what happens when it's finished.

Batches need a table to track progress, so run this once:

php artisan make:queue-batches-table
php artisan migrate

Then dispatch the batch with the callbacks you need:

use Illuminate\Bus\Batch;

$batch = Bus::batch(
    $tenants->map(fn (Tenant $tenant) => new GenerateStatement($tenant, $month))->all()
)->then(function (Batch $batch) {
    // every job succeeded
})->catch(function (Batch $batch, Throwable $e) {
    // the first failure happened
})->finally(function (Batch $batch) {
    // every job has run, successfully or not
})->name("Statements {$month}")->allowFailures()->dispatch();

return $batch->id; // keep it to show progress later

Later, Bus::findBatch($id) gives you progress(), processedJobs() and failedJobs, which is all you need for a progress bar in an admin screen.

Each job in the batch uses the Batchable trait and should check whether the batch was cancelled:

class GenerateStatement implements ShouldQueue
{
    use Batchable, Queueable;

    public function __construct(public Tenant $tenant, public string $month) {}

    public function handle(): void
    {
        if ($this->batch()?->cancelled()) {
            return;
        }

        // build and store the statement PDF
    }
}

Three batch gotchas

  • Without allowFailures(), one failure cancels the batch. But the remaining jobs are already on the queue, and workers will still pick them up. That cancelled check at the top of handle() is the only thing that stops them doing the work. Forget it and "cancelled" means nothing.
  • then only runs if every job succeeded, even with allowFailures(). If you want "the run is over, send me a summary either way", that's finally.
  • Callbacks are serialized and run later on a worker. Don't use $this inside them, and don't capture big objects. Pass IDs and look things up.

One more practical point: the batch table grows forever unless you prune it. Schedule queue:prune-batches and forget about it.

Unique jobs: don't queue the same work twice

Users double-click. Observers fire more often than you think. A scheduled command and a button can trigger the same recalculation at the same moment. If the job is expensive and the result would be identical, you only want one in the queue.

use Illuminate\Contracts\Queue\ShouldBeUnique;

class RecalculateTenantBalance implements ShouldQueue, ShouldBeUnique
{
    use Queueable;

    public $uniqueFor = 300; // seconds; the lock expires even if a worker dies

    public function __construct(public Tenant $tenant) {}

    public function uniqueId(): string
    {
        return (string) $this->tenant->id;
    }
}

On dispatch, Laravel takes a cache lock keyed by the job class and uniqueId(). While the lock exists, dispatching the same job again quietly does nothing. The lock is released when the job finishes or runs out of retries.

The details worth knowing:

  • It needs a cache driver that supports atomic locks. Redis, Memcached, database, DynamoDB and file all do. If your queue workers run on several servers, use a shared cache, not file.
  • Always set $uniqueFor. If a worker is killed mid-job, the lock otherwise lingers and the job silently refuses to be dispatched. That's a confusing bug to chase.
  • Consider ShouldBeUniqueUntilProcessing when new changes can arrive while the job is running. It releases the lock just before handle() starts, so a change that lands mid-run can queue one fresh recalculation instead of being dropped.

Also note what unique jobs don't do. They prevent duplicates in the queue. They don't stop two different jobs for the same tenant from running at the same time. For that, there's job middleware.

Job middleware: overlap and rate limits

WithoutOverlapping makes sure only one job with a given key runs at a time. Others are released back to the queue to try again later:

use Illuminate\Queue\Middleware\WithoutOverlapping;

public function middleware(): array
{
    return [(new WithoutOverlapping($this->tenant->id))->releaseAfter(10)->expireAfter(180)];
}

Rate limiting is for the outside world: SMS gateways, email APIs, anything with a quota. Define the limiter once, then attach it to the job:

// AppServiceProvider::boot()
RateLimiter::for('sms', function (object $job) {
    return Limit::perMinute(30);
});

// in the job
public function middleware(): array
{
    return [new RateLimited('sms')];
}

public function retryUntil(): DateTime
{
    return now()->addHour();
}

That retryUntil() is not decoration. When a job hits the limit, it's released back to the queue, and that counts as an attempt. With a normal $tries = 3, a busy hour can push perfectly healthy jobs into the failed table without a single real error. A time-based limit fits rate-limited work much better than a count.

A rule I like: a chain describes order, a batch describes a group, uniqueness describes the queue, and middleware describes execution. If you're reaching for sleep(), a lock you wrote by hand, or a flag column called is_processing, one of these four probably fits better.

Which tool for which problem

You need…Use
Steps that must run in order, each retrying on its ownBus::chain()
Many independent jobs with progress and a finish lineBus::batch()
The same work queued only onceShouldBeUnique
Never two at once for the same recordWithoutOverlapping
Respect an external API's quotaRateLimited + retryUntil()

They also combine. A batch can contain chains (put an array inside the batch array), and any job in either can carry middleware. The Laravel 12 queue docs cover the full set of options.

Which of these have you used in production, and which one bit you first? For me, the cancelled-batch check is the one I see forgotten most often.

Comments (0)
Leave your review

Thanks for your valuable comments. Your comments has been updated and appreciate your getting in touch...

01. About Shiroz

Mohammed Shiroz

Hi, I'm Mohammed Shiroz, a software engineer and AI enthusiast from Sri Lanka who turns ideas into intelligent, real-world solutions. With over 9 years of hands-on experience, I currently lead real estate ERP development at Kate Group, a...

03.My Projects

04. Categories

Ready To order Your Project ?

Get in Touch
Close