"The second batch starts before the first batch finishes."
That's what usually happens when you dispatch several Laravel queue batches one after another and expect them to run in sequence. The fix is to wrap the batches in Bus::chain(), so Laravel waits for one batch to finish before it starts the next.
The pattern is simple. Put your Bus::batch() definitions inside a single Bus::chain() call and call dispatch() only on the outer chain. Each batch still processes its own jobs concurrently, and the chain decides which batch runs next.
Step 1: Understand why separately dispatched batches overlap
Suppose your Laravel application imports products from a supplier. The process has three stages:
- Import products into the database.
- Generate thumbnails for the imported products.
- Notify the administrator when everything finishes.
Each stage depends on the one before it. You can't generate thumbnails before the products exist, and 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 they execute in order. Each dispatch() creates an independent batch, and all six jobs land on the queue straight away. With several workers running, you get logs like:
ImportProducts(1) started
GenerateThumbnail(1) started
ImportProducts(2) started
SendImportNotification(1) started
The notification can run before the import finishes. Laravel is doing exactly what it was told, because nothing links the batches together. Dispatching batches one after another doesn't create a dependency.
One detail hides this bug. With a single worker on the database driver, jobs are usually picked up in insertion order, so the sequence often looks correct in local testing. It breaks as soon as you add a second worker, or a job is released back to the queue for a retry.
You could dispatch the second batch from the first batch's then() callback, and the third from the second's. That works, but it buries the workflow in nested callbacks. When every stage is known up front, Bus::chain() is easier to read and maintain.
Step 2: Set up the queue and batchable jobs
Configure the database queue
In your project's .env:
QUEUE_CONNECTION=database
The database driver is convenient for testing because it doesn't need Redis.
Laravel needs three tables: jobs, job_batches and failed_jobs. Fresh Laravel 11+ projects already create all three in the default create_jobs_table migration, so check database/migrations before generating anything. If they're missing, run this on Laravel 11 and later:
php artisan make:queue-table
php artisan make:queue-batches-table
php artisan migrate
On Laravel 10 and older, the equivalent commands are php artisan queue:table and php artisan queue:batches-table. Don't generate a migration for a table that's already defined, or migrate will fail with a "table already exists" error.
What each table does:
-
jobsstores pending jobs for the database driver. -
job_batchesstores batch metadata: total jobs, pending jobs, failures, and the cancelled and finished timestamps. -
failed_jobsstores jobs that failed after all their attempts.
Start a worker and keep the terminal open:
php artisan queue:work database --tries=3 --verbose
To see jobs inside a batch actually run in parallel, open a second terminal and start another worker with the same command.
Workers are long-running processes and don't reload your code. After changing a job class or the queue config, run:
php artisan queue:restart
During testing, I'd avoid QUEUE_CONNECTION=sync. The sync driver runs jobs inside the current request, so it hides exactly the concurrency problems you're trying to check.
Create jobs that support batching
php artisan make:job ImportProducts
php artisan make:job GenerateThumbnail
php artisan make:job SendImportNotification
These jobs only write to the log instead of doing real imports. That way you can confirm the order before adding database or image-processing code.
app/Jobs/ImportProducts.php:
<?php
namespace App\Jobs;
use Illuminate\Bus\Batchable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Facades\Log;
class ImportProducts implements ShouldQueue
{
use Batchable, Queueable;
public function __construct(
public int $groupId
) {}
public function handle(): void
{
if ($this->batch()?->cancelled()) {
return;
}
Log::info("Import completed for group {$this->groupId}");
}
}
app/Jobs/GenerateThumbnail.php:
<?php
namespace App\Jobs;
use Illuminate\Bus\Batchable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Facades\Log;
class GenerateThumbnail implements ShouldQueue
{
use Batchable, Queueable;
public function __construct(
public int $groupId
) {}
public function handle(): void
{
if ($this->batch()?->cancelled()) {
return;
}
Log::info("Thumbnails completed for group {$this->groupId}");
}
}
app/Jobs/SendImportNotification.php:
<?php
namespace App\Jobs;
use Illuminate\Bus\Batchable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Facades\Log;
class SendImportNotification implements ShouldQueue
{
use Batchable, Queueable;
public function __construct(
public int $groupId
) {}
public function handle(): void
{
if ($this->batch()?->cancelled()) {
return;
}
Log::info("Notification completed for group {$this->groupId}");
}
}
Two lines here matter:
-
use Batchableis required. TheQueueabletrait doesn't include batching, and a job can't be added to a batch withoutBatchable. On Laravel 10 and older, the generated job usesDispatchable, InteractsWithQueue, Queueable, SerializesModelswithIlluminate\Bus\Queueable. AddBatchableto that list. -
The
cancelled()check stops a job from doing work after its batch has been cancelled. Laravel doesn't skip the remaining jobs in a cancelled batch on its own.
Every job receives a group ID rather than the data itself. In a real app, that ID might identify a product group, an import file or a customer account. Pass IDs and load the records inside handle(). That keeps serialized payloads small, and the job works on current data instead of a snapshot taken at dispatch time.
Step 3: Chain multiple Laravel queue batches with Bus::chain
Open Tinker:
php artisan tinker
Paste:
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();
Laravel now runs the workflow in stages:
BATCH 1: Import products
+-- ImportProducts(1) } can run in parallel
+-- ImportProducts(2) }
|
wait for batch 1 to finish
|
BATCH 2: Generate thumbnails
+-- GenerateThumbnail(1)
+-- GenerateThumbnail(2)
|
wait for batch 2 to finish
|
BATCH 3: Send notifications
+-- SendImportNotification(1)
+-- SendImportNotification(2)
Inside a batch, order isn't guaranteed. ImportProducts(2) might finish before ImportProducts(1), and that's fine. What matters is that the thumbnail batch isn't dispatched until the import batch has finished without being cancelled.
Under the hood, Laravel wraps each batch in the chain so that the next link is dispatched from the batch's finally callback, but only if the batch wasn't cancelled. Keep that in mind, because it explains the failure behaviour in Step 4.
The distinction to remember:
-
Bus::batch()groups jobs that can run independently and tracks their progress together. -
Bus::chain()runs its items one after another. - Put batches inside a chain and you get parallel work within each stage and strict order between stages.
The official Laravel queue documentation describes this pattern under "Chains and Batches".
If the jobs should go to a specific queue, set it on each batch. All jobs in one batch must use the same connection and queue:
Bus::chain([
Bus::batch([
new ImportProducts(1),
new ImportProducts(2),
])->name('Import products')->onQueue('imports'),
Bus::batch([
new GenerateThumbnail(1),
new GenerateThumbnail(2),
])->name('Generate thumbnails')->onQueue('imports'),
])->dispatch();
Don't dispatch the batches inside the chain
This is a common mistake:
Bus::chain([
Bus::batch([
new ImportProducts(1),
])->dispatch(),
Bus::batch([
new GenerateThumbnail(1),
])->dispatch(),
])->dispatch();
Each inner dispatch() starts its batch immediately, while the array is still being built. The chain then receives Batch objects that are already running instead of pending batch definitions, so the ordering you wanted never exists.
Use this instead:
Bus::chain([
Bus::batch([
new ImportProducts(1),
]),
Bus::batch([
new GenerateThumbnail(1),
]),
])->dispatch();
Only the outer chain gets dispatch().
Don't add sleep() between dispatch calls either. A delay isn't a dependency, and a slow job can still finish after it.
Step 4: Handle failures so the wrong batch never starts
Suppose ImportProducts(2) fails because the supplier's CSV contains invalid data. You don't want thumbnails generated from half-imported products.
By default it works like this:
- A job that throws is retried until it uses up its attempts (
--tries=3above). A job that fails on attempt 1 and succeeds on attempt 2 doesn't affect the batch. - When a job fails for good, Laravel marks its batch as cancelled.
- A cancelled batch doesn't let the chain continue, so the thumbnail and notification batches are never dispatched.
- Other jobs already queued in the cancelled batch still run. That's why each job checks
$this->batch()?->cancelled().
Attach a catch() callback to record what went wrong:
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 catch() callback runs only for the first job failure in the batch, not once per failed job. Batch callbacks are serialized and run later by a worker, so don't use $this inside them.
The callback only records the failure. It doesn't repair the data or restart anything.
Retrying doesn't resume the chain
This catches people out. Say you fix the CSV and run php artisan queue:retry-batch BATCH_ID (or queue:retry on the failed job). Two things work against you:
- The batch stays marked as cancelled, so the next stage is never dispatched.
- The retried job hits its own
cancelled()check and returns without doing anything. Laravel records it as a success, but no products were imported.
So the retry looks green and nothing actually happened. In practice, fix the cause and dispatch a fresh chain for the stages that still need to run:
Bus::chain([
Bus::batch([
new ImportProducts(1),
new ImportProducts(2),
])->name('Import products (rerun)'),
Bus::batch([
new GenerateThumbnail(1),
new GenerateThumbnail(2),
])->name('Generate thumbnails'),
Bus::batch([
new SendImportNotification(1),
new SendImportNotification(2),
])->name('Send notifications'),
])->dispatch();
That's only safe if your jobs are idempotent, meaning that running them twice doesn't create duplicate products or send a second email. Re-running group 1's import here is fine only if the import upserts rather than inserts.
What allowFailures() really changes
Bus::batch([
new ImportProducts(1),
new ImportProducts(2),
])
->allowFailures()
->name('Import products');
With allowFailures(), a failed job no longer cancels the batch. Inside a chain, that means the next batch starts once every job has run, even if some of them failed. The thumbnail stage would then run against incomplete imports.
I'd avoid it in this workflow unless the thumbnail jobs are written to skip products that don't exist.
Nested chains are a different thing
Laravel also supports chains inside a batch:
Bus::batch([
[
new ImportProducts(1),
new GenerateThumbnail(1),
],
[
new ImportProducts(2),
new GenerateThumbnail(2),
],
])->dispatch();
Each inner array is a chain. Group 1's thumbnails wait for group 1's import, but group 2 progresses independently, so group 1's thumbnails can be generated while group 2 is still importing.
Use nested chains when dependencies are local to each group. Use chained batches when a whole stage must finish before the next one starts.
Step 5: Fix batches that stay pending
If the chain is correct but nothing happens, I'd check the worker first:
php artisan queue:work database --tries=3 --verbose
If your batches use ->onQueue('imports'), a worker on the default queue won't touch them. Tell it which queues to process:
php artisan queue:work database --queue=imports,default
The worker checks imports before default.
Don't mix connections or queues inside a batch. All jobs in one batch have to share a connection and queue. If the stages genuinely need different infrastructure, give each batch its own onConnection() / onQueue() and run workers for each.
Check for failures:
php artisan queue:failed
Retry a single job after fixing the cause:
php artisan queue:retry YOUR_FAILED_JOB_ID
This retries one job only, and as Step 4 showed, a job from a cancelled batch exits immediately. Also watch out for jobs with side effects. A notification job might have sent its email before throwing, and retrying it without a duplicate check sends the email again.
The job_batches table grows with every dispatch. Prune old rows on a schedule (on Laravel 11+, in routes/console.php):
use Illuminate\Support\Facades\Schedule;
Schedule::command('queue:prune-batches --hours=48 --unfinished=72 --cancelled=72')->daily();
--hours only removes finished batches. Without --unfinished and --cancelled, the cancelled import batches from Step 4 stay in the table forever.
The scheduler only runs if the server's crontab calls php artisan schedule:run every minute. The cron job generator builds that line for you.
For big imports, keep each job's memory use low by working in chunks. Exporting large datasets with Laravel Excel chunks uses the same idea in the other direction. If your import jobs load CSVs through MySQL directly and the quoted fields come out wrong, see fixing LOAD DATA INFILE OPTIONALLY ENCLOSED BY problems.
Verify that Laravel actually executes the batches in order
With two workers running, dispatch the chain and watch the log.
Linux or macOS:
tail -f storage/logs/laravel.log
Windows PowerShell:
Get-Content .\storage\logs\laravel.log -Tail 30 -Wait
You should see something like:
local.INFO: Import completed for group 2
local.INFO: Import completed for group 1
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 within each stage can vary. What you're checking is that both imports finish before any thumbnail line appears, and both thumbnails finish before any notification.
To prove the failure path, temporarily add this line to ImportProducts::handle() after the cancelled() check:
if ($this->groupId === 2) {
throw new \RuntimeException('test');
}
Restart the workers with php artisan queue:restart, then dispatch again. After three attempts you should see the error logged by catch(), and no thumbnail or notification lines at all. Depending on which job a worker picks up first, group 1 may log "Import completed" or may exit early on the cancelled check. Either result is correct.
You can also inspect the batch metadata:
SELECT
id,
name,
total_jobs,
pending_jobs,
failed_jobs,
finished_at,
cancelled_at
FROM job_batches
ORDER BY created_at DESC
LIMIT 10;
created_at, finished_at and cancelled_at are stored as Unix timestamps, not datetimes. A successful batch has pending_jobs at 0 and a value in finished_at. A failed run shows cancelled_at filled on the import batch and no rows for the later batches, because they were never dispatched.
job_batches doesn't record a per-job timeline. If you need one, use the application log or a queue dashboard such as Horizon (Redis only).
For automated tests, Bus::fake() with Bus::assertChained() and Bus::chainedBatch() confirms that your code dispatches the right structure. That checks what gets dispatched, not how it runs, so keep one real-worker test like the one above for runtime behaviour.
FAQ
Can Laravel run multiple batches sequentially?
Yes. Put the Bus::batch() definitions inside one Bus::chain() and dispatch the chain. The next batch is dispatched only after the previous one finishes without being cancelled, while jobs inside each batch can still run in parallel.
Can I chain a normal Laravel job after a batch?
Yes. Chains can mix regular jobs and batches:
Bus::chain([
Bus::batch([
new ImportProducts(1),
new ImportProducts(2),
]),
new SendImportNotification(1),
])->dispatch();
The notification runs after the import batch completes. When you only need one final job, that's simpler than a whole batch. A job that runs outside a batch gets null from $this->batch(), so the ?->cancelled() check is safe there.
What happens if one job fails inside a Laravel batch?
Once the job has used up all its retry attempts, Laravel cancels the batch, and a chain containing that batch stops there. Retrying the failed job won't restart the chain. With allowFailures() the batch isn't cancelled, so the chain continues to the next stage even though some jobs failed.
Can I dispatch another batch from a then() callback?
Yes. Dispatching the next batch from then() works, and it's useful when the next stage depends on results you only know at runtime. For a fixed sequence, I'd use Bus::chain() because the whole workflow stays visible in one place.
Before you point this at production data, run the full chain with two real workers. Then force one failure to confirm that the later stages don't start, and make sure your import and notification jobs are safe to run twice.
Originally published at headsntail.com. Free tools for PHP, Laravel and cPanel work: headsntail.com/tools.
Top comments (0)