HNT-START
TITLE: Laravel Queue Batches: How to Chain Multiple Batches
SLUG: laravel-chain-multiple-queue-batches
EXCERPT: Learn how to chain multiple Laravel queue batches in order, run jobs in parallel within each batch, handle failures, and verify execution.

CATEGORY: Laravel

"The second batch starts before the first batch finishes."

That's the problem developers usually encounter when dispatching several Laravel queue batches and expecting them to execute in sequence. The solution is to combine Bus::chain() and Bus::batch() so Laravel waits for one batch to finish before starting the next.

If you're searching for laravel queue batches: how to chain multiple batches, the answer is to put Bus::batch() definitions inside a single Bus::chain() call. Each batch can process its jobs concurrently, while the outer chain controls which batch runs next. You only call dispatch() on the outer chain.

Step 1: Understand why multiple Laravel batches run at the same time

Suppose your Laravel application imports products from a supplier.

The process has three stages:

  1. Import products into the database.
  2. Generate thumbnails for imported products.
  3. Notify the administrator when everything finishes.

Each stage depends on the previous one.

You can't generate thumbnails before the products exist. You shouldn't send the completion notification while thumbnails are still being generated.

A common first attempt looks like this:

use Illuminate\Support\Facades\Bus; Bus::batch([ new ImportProducts(1), new ImportProducts(2), ])->dispatch(); Bus::batch([ new GenerateThumbnail(1), new GenerateThumbnail(2), ])->dispatch(); Bus::batch([ new SendImportNotification(1), new SendImportNotification(2), ])->dispatch(); 

The code dispatches three batches in order, but that doesn't mean Laravel will execute them in order.

Each dispatch() creates an independent batch.

When multiple queue workers are running, they can pick up jobs from different batches at nearly the same time.

This creates problems such as:

ImportProducts(1) started GenerateThumbnail(1) started ImportProducts(2) started SendImportNotification(1) started 

The notification can run before the import finishes.

The problem isn't with queue workers. Laravel is behaving as instructed because no dependency exists between the batches.

Dispatching batches one after another doesn't create a dependency.

You could dispatch the second batch from the first batch's then() callback, then dispatch the third from the second callback.

That approach works in some situations, but it creates unnecessary nested logic.

For a workflow where every stage is already known, Bus::chain() is easier to manage.

Step 2: Configure the Laravel database queue

Before chaining batches, make sure Laravel can process queued jobs.

Open your project's .env file and set:

QUEUE_CONNECTION=database 

The database driver is convenient for local testing because it doesn't require Redis or another queue server.

Laravel needs tables to store pending jobs, failed jobs, and batch information.

Check your existing database migrations first.

If your application doesn't already include the queue table migration, generate it:

php artisan make:queue-table 

Generate the batch metadata migration if it doesn't already exist:

php artisan make:queue-batches-table 

Then run:

php artisan migrate 

Some Laravel installations already include queue-related migrations. Don't create duplicate migrations for tables that are already defined.

Once migrations finish, your database should contain the relevant tables:

jobs failed_jobs job_batches 

The jobs table stores pending database queue jobs.

The failed_jobs table records failed queued jobs when failed-job storage is configured.

The job_batches table stores batch metadata, including job counts and completion information.

Now start a worker:

php artisan queue:work database --tries=3 

Keep the terminal open.

You can also use:

php artisan queue:work database --verbose --tries=3 

The verbose option provides more information about processed jobs.

If you change your queue configuration while workers are already running, restart them:

php artisan queue:restart 

Queue workers are long-running processes. They don't automatically reload every configuration or code change.

One thing I'd avoid during testing is using:

QUEUE_CONNECTION=sync 

The synchronous driver runs jobs during the request rather than providing normal background queue processing.

It can hide concurrency and worker-related problems.

Use a real queue connection when checking whether your batches execute in the required order.

Step 3: Create jobs that support Laravel batching

Generate the jobs for our product workflow:

php artisan make:job ImportProducts php artisan make:job GenerateThumbnail php artisan make:job SendImportNotification 

Laravel creates the following files:

app/Jobs/ImportProducts.php app/Jobs/GenerateThumbnail.php app/Jobs/SendImportNotification.php 

We'll use logging rather than actual product imports for the first test.

This makes it easier to identify whether the chain works before adding database imports or image-processing code.

Open app/Jobs/ImportProducts.php:

<?php namespace App\Jobs; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Queue\Queueable; use Illuminate\Support\Facades\Log; class ImportProducts implements ShouldQueue { use Queueable; public function __construct( public int $groupId ) {} public function handle(): void { Log::info( "Import completed for group {$this->groupId}" ); } } 

Next, update app/Jobs/GenerateThumbnail.php:

<?php namespace App\Jobs; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Queue\Queueable; use Illuminate\Support\Facades\Log; class GenerateThumbnail implements ShouldQueue { use Queueable; public function __construct( public int $groupId ) {} public function handle(): void { Log::info( "Thumbnails completed for group {$this->groupId}" ); } } 

Finally, edit app/Jobs/SendImportNotification.php:

<?php namespace App\Jobs; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Queue\Queueable; use Illuminate\Support\Facades\Log; class SendImportNotification implements ShouldQueue { use Queueable; public function __construct( public int $groupId ) {} public function handle(): void { Log::info( "Notification completed for group {$this->groupId}" ); } } 

These jobs implement ShouldQueue, allowing Laravel to process them through queue workers.

The Queueable trait in current Laravel versions also provides the batching support needed for this example.

In older Laravel versions, job classes may use separate traits, including:

use Illuminate\Bus\Batchable; 

If your jobs don't already include batch support through their traits, add Batchable.

Without the expected batching behavior, Laravel may fail when attempting to associate a job with its batch.

For this example, every job receives a group ID.

In a real application, the ID might identify a product group, import file, customer account, or database record.

Avoid passing large collections of database records directly into queued jobs.

Pass identifiers instead, then retrieve the required data inside handle().

This keeps serialized queue payloads smaller and reduces the risk of processing outdated data.

Step 4: Chain multiple Laravel batches using Bus::chain

Now we can fix the execution order.

Open Laravel Tinker:

php artisan tinker 

Enter the following code:

use App\Jobs\ImportProducts; use App\Jobs\GenerateThumbnail; use App\Jobs\SendImportNotification; use Illuminate\Support\Facades\Bus; Bus::chain([ Bus::batch([ new ImportProducts(1), new ImportProducts(2), ])->name('Import products'), Bus::batch([ new GenerateThumbnail(1), new GenerateThumbnail(2), ])->name('Generate thumbnails'), Bus::batch([ new SendImportNotification(1), new SendImportNotification(2), ])->name('Send notifications'), ])->dispatch(); 

This is the recommended structure for chaining several batches when each stage depends on the previous one.

Laravel processes the workflow in stages:

START | v BATCH 1: Import products | +-- ImportProducts(1) | +-- ImportProducts(2) | v Wait for batch 1 to finish | v BATCH 2: Generate thumbnails | +-- GenerateThumbnail(1) | +-- GenerateThumbnail(2) | v Wait for batch 2 to finish | v BATCH 3: Send notifications | +-- SendImportNotification(1) | +-- SendImportNotification(2) | v FINISHED 

The jobs inside an individual batch can run concurrently if enough queue workers are available.

For example, ImportProducts(2) might finish before ImportProducts(1).

That's fine.

What matters is that Laravel doesn't advance to the thumbnail batch until the import batch completes successfully under the normal failure rules.

The important distinction is:

  • Bus::batch() groups independently executable jobs.
  • Bus::chain() establishes sequential dependencies.
  • Combining them gives you parallel processing within each stage and ordered execution between stages.

Laravel documents this pattern in its official queue documentation.

Don't dispatch individual batches inside the chain

This is a common mistake:

Bus::chain([ Bus::batch([ new ImportProducts(1), ])->dispatch(), Bus::batch([ new GenerateThumbnail(1), ])->dispatch(), ])->dispatch(); 

It doesn't build the workflow you want.

Calling dispatch() on each batch starts those batches independently.

The chain expects pending batch definitions, not batches that have already been dispatched.

Use:

Bus::chain([ Bus::batch([ new ImportProducts(1), ]), Bus::batch([ new GenerateThumbnail(1), ]), ])->dispatch(); 

Only the outer chain needs dispatch().

Also, don't add sleep() calls between dispatch statements.

Sleeping for a few seconds doesn't establish a dependency. A slow job can still finish after the delay.

Let Laravel coordinate the sequence instead.

Step 5: Handle failed jobs without starting the wrong batch

Failure handling matters when later batches depend on earlier results.

Suppose ImportProducts(2) fails because the supplier's CSV contains invalid data.

You probably don't want the thumbnail batch to start using incomplete product records.

By default, Laravel cancels a batch after a job failure.

The chained workflow should therefore stop instead of continuing through the remaining dependent stages.

You can attach a batch-level failure callback:

use App\Jobs\ImportProducts; use App\Jobs\GenerateThumbnail; use Illuminate\Bus\Batch; use Illuminate\Support\Facades\Bus; use Illuminate\Support\Facades\Log; Bus::chain([ Bus::batch([ new ImportProducts(1), new ImportProducts(2), ]) ->name('Import products') ->catch(function (Batch $batch, \Throwable $e) { Log::error('Import batch failed', [ 'batch_id' => $batch->id, 'message' => $e->getMessage(), ]); }), Bus::batch([ new GenerateThumbnail(1), new GenerateThumbnail(2), ])->name('Generate thumbnails'), ])->dispatch(); 

The batch callback records useful failure details.

It doesn't automatically repair the failed record or restart the workflow.

You still need to investigate why the job failed.

One important detail: Laravel may retry a failed job according to its retry configuration.

A job can fail on one attempt and succeed on a later attempt.

However, the default batch cancellation behavior may already have marked the batch as cancelled after the failure.

Don't assume successful retries will always restart the remaining chain automatically.

If you want failed jobs to be tolerated, Laravel provides:

->allowFailures() 

For example:

Bus::batch([ new ImportProducts(1), new ImportProducts(2), ]) ->allowFailures() ->name('Import products'); 

That changes the batch's cancellation behavior.

It doesn't mean that failed jobs become successful or that every downstream dependency is satisfied.

I'd avoid allowing failures in this particular workflow unless the thumbnail stage is explicitly designed to handle missing products.

Why nested chains are different

Laravel also supports chains inside batches.

For example:

Bus::batch([ [ new ImportProducts(1), new GenerateThumbnail(1), ], [ new ImportProducts(2), new GenerateThumbnail(2), ], ])->dispatch(); 

Here, each nested array represents a chain of jobs.

Within group 1, thumbnail generation follows its import.

Within group 2, the same dependency exists.

But the two groups can progress independently.

That isn't equivalent to waiting until every product import finishes before starting any thumbnail generation.

Use nested chains when dependencies are local to each group.

Use chained batches when an entire processing stage must finish before the next stage starts.

Step 6: Fix batches that remain pending

Sometimes your chain is correct, but the jobs don't execute.

The first thing I'd check is whether the queue worker is running.

Start one:

php artisan queue:work database --verbose --tries=3 

If you're dispatching to a specific queue, make sure the worker listens to it.

For example, a worker that only processes the default queue won't pick up jobs waiting on the imports queue.

Run:

php artisan queue:work database --queue=imports,default 

This tells the worker to check imports before default.

Another issue is mixing queue connections inside a single batch.

Laravel requires jobs belonging to the same batch to use the same queue connection and queue.

Avoid assigning some jobs in one batch to Redis and others to the database driver.

If you need different queue infrastructure for separate stages, design the workflow explicitly around those connection requirements.

Check failed jobs using:

php artisan queue:failed 

Laravel will display the failed job identifiers and exception information available through its failed-job storage.

After fixing a problem, you can retry an individual failed job:

php artisan queue:retry YOUR_FAILED_JOB_ID 

Replace YOUR_FAILED_JOB_ID with the actual failed job ID.

Be careful with retries if a job performs external operations.

For example, a failed notification job might already have sent an email before an exception occurred.

Retrying it without duplicate protection could send the email again.

Use idempotent job logic where possible.

That means running the same job twice shouldn't create unintended duplicate records or actions.

For large import workflows, batching should also work alongside chunked data processing.

See exporting large datasets with Laravel Excel chunks for related memory-management techniques.

If your jobs load CSV files directly through MySQL, fixing LOAD DATA INFILE quoted field problems can help with parsing failures.

For applications displaying the processed data on the frontend, the guide on retrieving database records using Vue and Laravel covers the next part of that workflow.

Verify that Laravel actually executes the batches in order

Once you've dispatched the chain, inspect Laravel's application log.

For a standard single-file log configuration on Linux:

tail -f storage/logs/laravel.log 

On Windows PowerShell:

Get-Content .\storage\logs\laravel.log -Tail 30 -Wait 

Your test should produce entries similar to:

local.INFO: Import completed for group 1 local.INFO: Import completed for group 2 local.INFO: Thumbnails completed for group 1 local.INFO: Thumbnails completed for group 2 local.INFO: Notification completed for group 1 local.INFO: Notification completed for group 2 

The order of jobs inside each batch may differ.

That's expected.

The check is whether all import jobs finish before thumbnail jobs begin, and all thumbnail jobs finish before notifications begin.

You can also inspect batch metadata through MySQL:

SELECT id, name, total_jobs, pending_jobs, failed_jobs, finished_at, cancelled_at FROM job_batches ORDER BY created_at DESC; 

The batch records show pending job counts, failures, and completion details.

For a successful completed batch, pending_jobs should reach zero, and finished_at should be populated.

If the batch remains unfinished, inspect the worker and the failed-job records.

One limitation: database batch metadata doesn't provide a complete timeline of every job execution.

Use application logs or queue monitoring if you need that level of detail.

For automated tests, Laravel offers Bus::fake() and assertions such as Bus::assertChained().

Laravel also provides Bus::chainedBatch() for testing chained batch definitions.

These tools help confirm that your application dispatches the intended workflow.

They don't replace an integration test with a real worker when you need to verify runtime behavior.

FAQ

Can Laravel run multiple batches sequentially?

Yes. Put multiple Bus::batch() definitions inside one Bus::chain() call.

Laravel coordinates batch execution so the next batch starts after the previous batch finishes successfully. Individual jobs inside each batch can still run concurrently.

Can I chain a normal Laravel job after a batch?

Yes. Laravel supports mixing regular jobs and batches within a chain.

For example:

Bus::chain([ Bus::batch([ new ImportProducts(1), new ImportProducts(2), ]), new SendImportNotification(1), ])->dispatch(); 

The notification job runs after the import batch finishes successfully. This is useful when you only need one final job instead of a whole notification batch.

What happens if one job fails inside a Laravel batch?

By default, Laravel cancels the batch when a job fails.

When that batch belongs to a larger chain, cancellation prevents the dependent workflow from continuing normally. You can attach batch failure callbacks to record errors and investigate the failed job.

What is the difference between Bus::batch and Bus::chain?

Bus::batch() groups jobs that can execute independently and provides batch-level progress tracking.

Bus::chain() runs dependent stages in sequence. By placing batches inside a chain, you can process several jobs concurrently within each stage while preserving the required order between stages.

Can I dispatch another batch from a then() callback?

Yes. Laravel allows you to dispatch another batch from a successful batch's then() callback.

That approach can be useful when the next stage is determined dynamically after processing. For a known sequence of dependent stages, I'd prefer Bus::chain() because the entire workflow is visible in one place.

For help diagnosing queue worker failures or batch execution problems, you can contact HeadsNTail.

For laravel queue batches: how to chain multiple batches, the next step is to test the complete workflow with a real queue worker, confirm each stage finishes before the next begins, and add failure handling before running it against production data.

HNT-END

Originally published at headsntail.com. Free tools for PHP, Laravel and cPanel work: headsntail.com/tools.