When you dump 50,000 tasks onto a standard queue, trackability vanishes. You get single-job retry mechanisms and logging, but no native way to answer basic user questions like "How far along is my export?" or "Can I stop this import right now?" That's where Bus::batch() comes in.
Laravel job batching tracks a collection of queued jobs using a dedicated database table. It coordinates execution, captures failures, and exposes state metrics like total jobs, pending jobs, and completion percentage. Pair it with Next.js 16 on the frontend, and you can give your users live feedback with minimal overhead.
Database Setup and Job Preparation
Before dispatching anything, you need the batch database migration. If you haven't published it yet, run:
php artisan queue:batches-table
php artisan migrateThis creates the job_batches table. It holds metadata like total_jobs, pending_jobs, failed_jobs, failed_job_ids, and serialized callback payloads. It also tracks timestamps for cancelled_at and finished_at.
Next, your job classes must use the Illuminate\Bus\Batchable trait. Without this trait, your job won't have access to the underlying $this->batch() helper instance during runtime.
namespace App\Jobs;
use Illuminate\Bus\Batchable;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use App\Models\User;
class ProcessUserExport implements ShouldQueue
{
use Batchable, Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public function __construct(public User $user) {}
public function handle(): void
{
// Exit early if the batch was cancelled while this job sat in Redis
if ($this->batch()?->cancelled()) {
return;
}
// Export logic here...
}
}Notice the explicit check for $this->batch()?->cancelled() at the start of handle(). When a user cancels a batch, Laravel marks the record in job_batches as cancelled, but it doesn't instantly yank queued payloads out of Redis queues. Inspecting cancelled() inside the job ensures you don't spend CPU cycles executing leftover jobs from an aborted batch.
Constructing Batches with Bus::batch
You dispatch batches using the Bus::batch() method. It accepts an array or an Enumerable collection of jobs, along with lifecycle callbacks: then(), catch(), and finally().
namespace App\Http\Controllers;
use App\Jobs\ProcessUserExport;
use App\Models\User;
use Illuminate\Bus\Batch;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Bus;
use Illuminate\Support\Facades\Log;
use Throwable;
class ExportController extends Controller
{
public function store(): JsonResponse
{
$jobs = User::query()
->lazyById(1000)
->map(fn (User $user) => new ProcessUserExport($user));
$batch = Bus::batch($jobs)
->name('user-export-' . now()->timestamp)
->allowFailures()
->then(function (Batch $batch) {
Log::info("Batch {$batch->id} completed successfully.");
})
->catch(function (Batch $batch, Throwable $e) {
Log::error("Batch {$batch->id} hit a failure: {$e->getMessage()}");
})
->finally(function (Batch $batch) {
Log::info("Batch {$batch->id} finished executing all jobs.");
})
->dispatch();
return response()->json([
'batch_id' => $batch->id,
]);
}
}Understanding Canceled Batches and Failure Isolation
By default, if a single job inside a batch throws an uncaught exception, Laravel flags the batch as cancelled right away. No further jobs from that batch will execute if they check $batch->cancelled().
If you prefer the remaining jobs to finish regardless of individual exceptions, call allowFailures() during dispatch. This keeps the batch alive, incrementing failed_jobs while letting sibling jobs process normally. The catch() callback will still execute on the first exception encountered, while finally() triggers once every job has either succeeded or failed.
Building the Next.js 16 and React 19 Progress Monitor
Once your API controller returns the batch_id, the frontend needs to check progress. Rather than setting up complex WebSocket pipelines for short-lived tasks, simple HTTP polling against a thin Laravel status endpoint works cleanly.
First, create an endpoint in Laravel to expose batch statistics:
use Illuminate\Support\Facades\Bus;
use Illuminate\Http\JsonResponse;
public function show(string $id): JsonResponse
{
$batch = Bus::findBatch($id);
if (!$batch) {
return response()->json(['error' => 'Batch not found'], 404);
}
return response()->json([
'id' => $batch->id,
'total_jobs' => $batch->totalJobs,
'pending_jobs' => $batch->pendingJobs,
'failed_jobs' => $batch->failedJobs,
'progress' => $batch->progress(),
'finished' => $batch->finished(),
'cancelled' => $batch->cancelled(),
]);
}Now build a React 19 component in Next.js 16 to poll this route until completion:
'use client';
import { useState, useEffect } from 'react';
interface BatchStatus {
id: string;
total_jobs: number;
pending_jobs: number;
failed_jobs: number;
progress: number;
finished: boolean;
cancelled: boolean;
}
export default function BatchTracker({ batchId }: { batchId: string }) {
const [status, setStatus] = useState<BatchStatus | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let timer: NodeJS.Timeout;
const fetchStatus = async () => {
try {
const res = await fetch(`/api/exports/batch/${batchId}`);
if (!res.ok) throw new Error('Failed to fetch batch status');
const data: BatchStatus = await res.json();
setStatus(data);
if (!data.finished && !data.cancelled) {
timer = setTimeout(fetchStatus, 2000);
}
} catch (err: any) {
setError(err.message);
}
};
fetchStatus();
return () => clearTimeout(timer);
}, [batchId]);
const cancelBatch = async () => {
await fetch(`/api/exports/batch/${batchId}/cancel`, { method: 'POST' });
};
if (error) return <div className="text-red-600">Error: {error}</div>;
if (!status) return <div>Loading status...</div>;
return (
<div className="p-4 border rounded shadow-sm max-w-md">
<div className="flex justify-between mb-2">
<span className="font-bold">Export Progress</span>
<span>{status.progress}%</span>
</div>
<div className="w-full bg-gray-200 h-4 rounded overflow-hidden mb-4">
<div
className="bg-blue-600 h-full transition-all duration-300"
style={{ width: `${status.progress}%` }}
/>
</div>
<div className="text-sm text-gray-600 mb-4">
Pending: {status.pending_jobs} | Failed: {status.failed_jobs}
</div>
{!status.finished && !status.cancelled && (
<button
onClick={cancelBatch}
className="bg-red-500 text-white px-3 py-1 rounded text-sm hover:bg-red-600"
>
Cancel Export
</button>
)}
</div>
);
}Production Traps: Memory, Race Conditions, and Redis
Batching works out of the box, but scaling it up hits real-world bottlenecks fast.
1. Memory exhaustion during dispatch
If you build an array of 200,000 job objects before calling Bus::batch(), PHP memory limits will crash your script. Use lazyById() or database cursor streaming to build collections lazily, or pass a generator function directly into Bus::batch().
2. The closure serialization problem
Laravel serializes your then(), catch(), and finally() closures directly into the job_batches table using Laravel\SerializableClosure. If you capture huge variables, models, or service instances inside those closures, your DB insert will fail or bloat dramatically. Keep closure scopes minimal. Pass IDs instead of whole model instances.
3. Race conditions on fast jobs
If your batch contains tiny jobs that complete in under 5 milliseconds, worker processes will finish jobs before Laravel finishes inserting the initial batch record into the database. To prevent this, use Bus::batch($jobs)->dispatchNextJobInEngine() or ensure you chain dispatch() after fully building the batch construct.












