Back to blog
Laravel
Intermediate

Laravel Queue Job Batching with Bus::batch() - Complete Guide

Laravel's Bus::batch() allows you to group multiple jobs into a single batch with powerful features like progress tracking, completion callbacks, and failure handling. This complete guide covers everything from basic usage to production-ready patterns.

January 15, 202518 min read

Introduction

Laravel's queue system is one of the framework's most powerful features, enabling developers to defer time-consuming tasks to background workers. While individual job dispatching handles most use cases, real-world applications often need to execute groups of related jobs as a single atomic unit. Enter job batching — a feature introduced in Laravel 8 that allows you to dispatch multiple jobs simultaneously, track their collective progress, and execute callbacks when the entire batch completes or fails.

The Bus::batch() method transforms how you handle bulk operations: sending thousands of notification emails, processing large CSV imports, generating reports for multiple tenants, or synchronizing data across microservices. Instead of managing individual job states manually, you get a first-class batch object with built-in progress tracking, cancellation support, and completion/failure callbacks.

This guide covers everything you need to master Laravel job batching — from fundamental concepts to production-ready patterns. We'll explore the Batch class, Batchable interface, callback chains, failure handling strategies, and integration with Laravel Horizon for monitoring. By the end, you'll be equipped to implement robust batch processing in your Laravel applications.

Table of Contents

Core Concepts

What Is a Job Batch?

A job batch is a collection of queued jobs that are grouped together and treated as a single unit of work. Each job in the batch runs independently, but the batch itself provides aggregate information: total job count, completed jobs, failed jobs, pending jobs, and overall progress percentage. The batch persists in your queue's database table (or Redis) with a unique UUID, allowing you to query its status at any time.

The Bus Facade and Batchable Interface

The Illuminate\Support\Facades\Bus facade provides the batch() method for creating batches. Jobs that participate in batching must implement the Illuminate\Bus\Batchable trait (not an interface — it's a trait that provides the batch() method on the job instance). This trait gives each job access to its parent batch via $this->batch(), enabling jobs to interact with batch-level data or check if the batch was cancelled.

Batch Callbacks

Batches support three types of callbacks that execute at different lifecycle stages:

  • then() — Executes when all jobs in the batch complete successfully
  • catch() — Executes when any job in the batch fails (first failure)
  • finally() — Executes when the batch finishes (either all succeeded or at least one failed)

These callbacks receive the Illuminate\Bus\Batch instance, giving you access to batch metadata, failed job exceptions, and the ability to dispatch additional work.

Batch Properties and Methods

The Batch class exposes several useful properties and methods:

  • id — Unique UUID for the batch
  • name — Optional human-readable name
  • totalJobs — Total number of jobs in the batch
  • pendingJobs — Jobs not yet processed
  • failedJobs — Jobs that have failed
  • processedJobs() — Count of completed jobs (success + failed)
  • progress() — Completion percentage (0-100)
  • cancelled() — Whether the batch was cancelled
  • cancel() — Cancel pending jobs in the batch

Architecture Overview

Understanding how batching works under the hood helps you design better systems. When you call Bus::batch([...])->dispatch(), Laravel performs several operations:

  1. Batch Record Creation — A record is inserted into the job_batches table (database queue) or stored in Redis with the batch UUID, total job count, and initial state.
  2. Job Enqueueing — Each job in the array is individually enqueued to the queue connection/queue specified. Each job receives the batch UUID in its payload.
  3. Worker Processing — Queue workers pick up jobs normally. When a job with a batch UUID is processed, the worker updates the batch record: increments processed count, updates progress, and checks for completion.
  4. Callback Execution — When the last job finishes (success or failure), the worker triggers the appropriate callbacks (then, catch, finally) by dispatching internal callback jobs.

This architecture means batch callbacks run asynchronously — they're queued jobs themselves, not synchronous PHP execution. This has important implications for database transactions, external API calls, and error handling in callbacks.

Database Schema

Laravel's migration for job batches creates a job_batches table with these key columns:

  • id (UUID, primary key)
  • name (string, nullable)
  • total_jobs (integer)
  • pending_jobs (integer)
  • failed_jobs (integer)
  • failed_job_ids (JSON array of failed job UUIDs)
  • options (JSON, stores callback serialization)
  • cancelled_at (timestamp, nullable)
  • created_at, updated_at

Run php artisan queue:batches-table and php artisan migrate to create this table if using the database queue driver.

Step-by-Step Guide

Step 1: Prepare Your Jobs

Each job that will be part of a batch must use the Batchable trait:

<?phpnamespace App\Jobs;use Illuminate\Bus\Queueable;use Illuminate\Contracts\Queue\ShouldQueue;use Illuminate\Foundation\Bus\Dispatchable;use Illuminate\Queue\InteractsWithQueue;use Illuminate\Bus\Batchable; // Important!use Illuminate\Queue\SerializesModels;class ProcessCsvRow implements ShouldQueue{    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels, Batchable;    public function __construct(        public readonly array $rowData,        public readonly int $rowIndex    ) {}    public function handle(): void    {        // Access batch info if needed        $batch = $this->batch();                // Check if batch was cancelled        if ($batch && $batch->cancelled()) {            return; // Exit early        }        // Process the row        // ...    }}

Step 2: Create and Dispatch a Batch

Use the Bus facade to create a batch from an array of job instances:

<?phpuse App\Jobs\ProcessCsvRow;use Illuminate\Support\Facades\Bus;use Illuminate\Bus\Batch;$rows = [    ['name' => 'Alice', 'email' => 'alice@example.com'],    ['name' => 'Bob', 'email' => 'bob@example.com'],    ['name' => 'Carol', 'email' => 'carol@example.com'],    // ... thousands more];$batch = Bus::batch([    new ProcessCsvRow($rows[0], 0),    new ProcessCsvRow($rows[1], 1),    new ProcessCsvRow($rows[2], 2),    // ...])->then(function (Batch $batch) {    // All jobs succeeded    })->catch(function (Batch $batch, Throwable $e) {    // First job failure    })->finally(function (Batch $batch) {    // Batch finished (success or failure)    })->dispatch();// Get the batch ID for tracking$batchId = $batch->id;

Step 3: Monitor Batch Progress

Retrieve batch status anytime using the Bus::findBatch() method:

<?phpuse Illuminate\Support\Facades\Bus;$batch = Bus::findBatch($batchId);if ($batch) {    echo "Progress: " . $batch->progress() . "%";    echo "Pending: " . $batch->pendingJobs . "";    echo "Failed: " . $batch->failedJobs . "";    echo "Total: " . $batch->totalJobs . "";}

Step 4: Handle Batch Callbacks

Implement meaningful logic in your callbacks. The then callback runs only on full success; catch runs on first failure (but other jobs may still be processing); finally always runs last:

<?phpuse App\Notifications\BatchCompleted;use App\Notifications\BatchFailed;use Illuminate\Support\Facades\Bus;use Illuminate\Bus\Batch;use Throwable;$batch = Bus::batch($jobs)    ->name('Monthly Report Generation')    ->then(function (Batch $batch) {        // All jobs succeeded - notify stakeholders        $admin = User::find(1);        $admin->notify(new BatchCompleted($batch));                // Trigger downstream process        GenerateSummaryReport::dispatch($batch->id);    })    ->catch(function (Batch $batch, Throwable $e) {        // Log the failure        Log::error('Batch failed', [            'batch_id' => $batch->id,            'exception' => $e->getMessage(),            'failed_job_count' => $batch->failedJobs,        ]);                // Notify admin        $admin = User::find(1);        $admin->notify(new BatchFailed($batch, $e));    })    ->finally(function (Batch $batch) {        // Cleanup - always runs        TemporaryFileCleanup::dispatch($batch->id);                // Update batch status in your tracking table        BatchRecord::where('batch_id', $batch->id)            ->update(['status' => $batch->cancelled() ? 'cancelled' : 'completed']);    })    ->dispatch();

Step 5: Cancel a Running Batch

You can cancel a batch at any time, which prevents pending jobs from being processed:

<?php$batch = Bus::findBatch($batchId);if ($batch && !$batch->cancelled()) {    $batch->cancel();        // The finally callback will still execute    // Jobs already processed remain completed    // Pending jobs are marked as cancelled}

Step 6: Allow Failures Without Stopping the Batch

By default, a single job failure triggers the catch callback but other jobs continue. If you want to allow failures without triggering catch (treating failures as acceptable), use allowFailures():

<?php$batch = Bus::batch($jobs)    ->allowFailures() // Failures don't trigger catch(), only finally()    ->then(function (Batch $batch) {        // Runs even if some jobs failed    })    ->finally(function (Batch $batch) {        // Always runs        $successCount = $batch->totalJobs - $batch->failedJobs;        Log::info("Batch completed: {$successCount}/{$batch->totalJobs} succeeded");    })    ->dispatch();

Real-World Examples

Example 1: Bulk Email Notification with Progress Tracking

Send personalized emails to 10,000+ subscribers with real-time progress updates via websockets or polling:

<?phpnamespace App\Jobs;use App\Models\Subscriber;use App\Mail\NewsletterEmail;use Illuminate\Bus\Queueable;use Illuminate\Contracts\Queue\ShouldQueue;use Illuminate\Foundation\Bus\Dispatchable;use Illuminate\Queue\InteractsWithQueue;use Illuminate\Bus\Batchable;use Illuminate\Queue\SerializesModels;use Illuminate\Support\Facades\Mail;use Illuminate\Support\Facades\Log;class SendNewsletterToSubscriber implements ShouldQueue{    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels, Batchable;    public function __construct(        public readonly int $subscriberId,        public readonly int $campaignId    ) {}    public function handle(): void    {        $subscriber = Subscriber::find($this->subscriberId);        $campaign = Campaign::find($this->campaignId);                if (!$subscriber || !$campaign) {            return; // Silently skip missing records        }        // Check batch cancellation        if ($this->batch() && $this->batch()->cancelled()) {            return;        }        Mail::to($subscriber->email)            ->queue(new NewsletterEmail($campaign, $subscriber));    }}// In your controller or command:$subscriberIds = Subscriber::where('subscribed', true)    ->pluck('id')    ->toArray();$jobs = array_map(    fn($id) => new SendNewsletterToSubscriber($id, $campaignId),    $subscriberIds);Bus::batch($jobs)    ->name("Newsletter Campaign #{$campaignId}")    ->then(function (Batch $batch) {        Campaign::find($this->campaignId)            ->update(['status' => 'sent', 'sent_at' => now()]);    })    ->catch(function (Batch $batch, Throwable $e) {        Log::error('Newsletter batch failed', [            'campaign_id' => $campaignId,            'error' => $e->getMessage(),        ]);    })    ->finally(function (Batch $batch) {        // Update progress cache for frontend polling        Cache::forget("batch_progress_{$batch->id}");    })    ->dispatch();

Example 2: Large CSV Import with Chunked Batches

Process a 100,000-row CSV by splitting into batches of 1,000 rows each, with a parent batch coordinating child batches:

<?phpnamespace App\Jobs;use Illuminate\Bus\Queueable;use Illuminate\Contracts\Queue\ShouldQueue;use Illuminate\Foundation\Bus\Dispatchable;use Illuminate\Queue\InteractsWithQueue;use Illuminate\Bus\Batchable;use Illuminate\Queue\SerializesModels;use Illuminate\Support\Facades\Bus;use Illuminate\Bus\Batch;use App\Models\ImportSession;class ProcessCsvChunk implements ShouldQueue{    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels, Batchable;    public function __construct(        public readonly string $filePath,        public readonly int $startRow,        public readonly int $endRow,        public readonly int $importSessionId    ) {}    public function handle(): void    {        $session = ImportSession::find($this->importSessionId);                if ($this->batch() && $this->batch()->cancelled()) {            return;        }        $handle = fopen($this->filePath, 'r');        // Skip to start row        for ($i = 0; $i < $this->startRow; $i++) {            fgetcsv($handle);        }        for ($rowNum = $this->startRow; $rowNum <= $this->endRow; $rowNum++) {            $row = fgetcsv($handle);            if (!$row) break;                        // Process row...            Product::updateOrCreate(                ['sku' => $row[0]],                ['name' => $row[1], 'price' => $row[2]]            );        }        fclose($handle);                // Update session progress        $session->increment('processed_rows', $this->endRow - $this->startRow + 1);    }}class OrchestrateCsvImport implements ShouldQueue{    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;    public function __construct(        public readonly string $filePath,        public readonly int $importSessionId,        public readonly int $chunkSize = 1000    ) {}    public function handle(): void    {        $session = ImportSession::find($this->importSessionId);        $rowCount = $this->countCsvRows($this->filePath);        $session->update(['total_rows' => $rowCount]);        $chunks = [];        for ($start = 1; $start < $rowCount; $start += $this->chunkSize) {            $end = min($start + $this->chunkSize - 1, $rowCount);            $chunks[] = new ProcessCsvChunk(                $this->filePath,                $start,                $end,                $this->importSessionId            );        }        Bus::batch($chunks)            ->name("CSV Import Session #{$this->importSessionId}")            ->then(function (Batch $batch) use ($session) {                $session->update(['status' => 'completed']);                ImportCompleted::dispatch($session->id);            })            ->catch(function (Batch $batch, Throwable $e) use ($session) {                $session->update(['status' => 'failed', 'error' => $e->getMessage()]);            })            ->finally(function (Batch $batch) use ($session) {                // Cleanup temp file                unlink($this->filePath);            })            ->dispatch();    }    private function countCsvRows(string $path): int    {        $handle = fopen($path, 'r');        $count = 0;        while (fgetcsv($handle)) $count++;        fclose($handle);        return $count - 1; // Exclude header    }}

Example 3: Multi-Tenant Report Generation

Generate monthly reports for all tenants in a SaaS application, with per-tenant job isolation and aggregated notifications:

<?phpnamespace App\Jobs;use App\Models\Tenant;use App\Services\ReportGenerator;use Illuminate\Bus\Queueable;use Illuminate\Contracts\Queue\ShouldQueue;use Illuminate\Foundation\Bus\Dispatchable;use Illuminate\Queue\InteractsWithQueue;use Illuminate\Bus\Batchable;use Illuminate\Queue\SerializesModels;use Illuminate\Support\Facades\Storage;use Illuminate\Support\Facades\Log;class GenerateTenantReport implements ShouldQueue{    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels, Batchable;    public function __construct(        public readonly int $tenantId,        public readonly string $period    ) {}    public function handle(): void    {        $tenant = Tenant::find($this->tenantId);                if (!$tenant) {            throw new \Exception("Tenant {$this->tenantId} not found");        }        if ($this->batch() && $this->batch()->cancelled()) {            return;        }        $generator = new ReportGenerator($tenant);        $pdfPath = $generator->generateMonthlyReport($this->period);                // Store in tenant's private storage        Storage::disk('tenant-reports')            ->put("monthly/{$this->period}.pdf", file_get_contents($pdfPath));                // Update tenant's report status        $tenant->reports()            ->updateOrCreate(['period' => $this->period], [                'status' => 'generated',                'path' => "monthly/{$this->period}.pdf",                'generated_at' => now(),            ]);    }}// Dispatch from a scheduled command:$tenants = Tenant::where('active', true)->get();$period = now()->subMonth()->format('Y-m');$jobs = $tenants->map(fn($t) => new GenerateTenantReport($t->id, $period));Bus::batch($jobs->toArray())    ->name("Monthly Reports - {$period}")    ->allowFailures() // One tenant failure shouldn't block others    ->then(function (Batch $batch) use ($period) {        $successCount = $batch->totalJobs - $batch->failedJobs;        Log::info("Monthly reports generated: {$successCount}/{$batch->totalJobs}");                // Notify super-admin        User::role('super-admin')            ->each->notify(new MonthlyReportsReady($period, $successCount));    })    ->catch(function (Batch $batch, Throwable $e) {        // With allowFailures(), catch only triggers if ALL jobs fail        Log::critical('All tenant reports failed', ['error' => $e->getMessage()]);    })    ->finally(function (Batch $batch) {        // Clear generation lock        Cache::forget('monthly_report_generation_lock');    })    ->dispatch();

Production Code Examples

Batch Progress API Endpoint

Build a REST endpoint for frontend polling of batch progress:

<?phpnamespace App\Http\Controllers\Api;use App\Http\Controllers\Controller;use Illuminate\Http\JsonResponse;use Illuminate\Support\Facades\Bus;use Illuminate\Bus\Batch;class BatchProgressController extends Controller{    public function show(string $batchId): JsonResponse    {        $batch = Bus::findBatch($batchId);                if (!$batch) {            return response()->json([                'error' => 'Batch not found'            ], 404);        }        // Check authorization - user can only see their batches        $this->authorizeBatchAccess($batch);        return response()->json([            'id' => $batch->id,            'name' => $batch->name,            'progress' => round($batch->progress(), 2),            'total_jobs' => $batch->totalJobs,            'pending_jobs' => $batch->pendingJobs,            'failed_jobs' => $batch->failedJobs,            'processed_jobs' => $batch->processedJobs(),            'cancelled' => $batch->cancelled(),            'finished' => $batch->pendingJobs === 0,            'created_at' => $batch->created_at?->toISOString(),            'finished_at' => $batch->cancelled_at?->toISOString(),        ]);    }    public function cancel(string $batchId): JsonResponse    {        $batch = Bus::findBatch($batchId);                if (!$batch) {            return response()->json(['error' => 'Batch not found'], 404);        }        $this->authorizeBatchAccess($batch);        if ($batch->cancelled()) {            return response()->json(['message' => 'Batch already cancelled']);        }        $batch->cancel();        return response()->json(['message' => 'Batch cancellation requested']);    }    private function authorizeBatchAccess(Batch $batch): void    {        // Implement your authorization logic        // e.g., check if batch belongs to current user's organization        $batchRecord = BatchRecord::where('batch_id', $batch->id)->first();                if (!$batchRecord || $batchRecord->user_id !== auth()->id()) {            abort(403, 'Unauthorized access to batch');        }    }}

Batch-Aware Job Middleware for Rate Limiting

Apply rate limiting per batch to avoid overwhelming external APIs:

<?phpnamespace App\Jobs\Middleware;use Closure;use Illuminate\Support\Facades\Redis;use Illuminate\Support\Facades\Log;class BatchRateLimited{    public function __construct(        public int $maxPerMinute = 60,        public string $keyPrefix = 'batch_rate_limit'    ) {}    public function handle(mixed $job, Closure $next): void    {        $batch = $job->batch();                if (!$batch) {            $next($job);            return;        }        $key = "{$this->keyPrefix}:{$batch->id}";        $current = Redis::incr($key);        if ($current === 1) {            Redis::expire($key, 60); // 1 minute window        }        if ($current > $this->maxPerMinute) {            // Re-queue with delay            $job->release(60);            Log::debug("Batch {$batch->id} rate limited, re-queuing job {$job->getJobId()}");            return;        }        $next($job);    }}// Usage in job:class CallExternalApi implements ShouldQueue{    use Batchable, Dispatchable, InteractsWithQueue, Queueable, SerializesModels;    public function middleware(): array    {        return [new BatchRateLimited(maxPerMinute: 30)];    }        // ...}

Batch Completion Webhook Dispatcher

Automatically notify external systems when batches complete:

<?phpnamespace App\Jobs;use Illuminate\Bus\Queueable;use Illuminate\Contracts\Queue\ShouldQueue;use Illuminate\Foundation\Bus\Dispatchable;use Illuminate\Queue\InteractsWithQueue;use Illuminate\Queue\SerializesModels;use Illuminate\Support\Facades\Http;use Illuminate\Support\Facades\Log;class DispatchBatchCompletionWebhook implements ShouldQueue{    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;    public function __construct(        public readonly string $batchId,        public readonly string $webhookUrl,        public readonly array $payload = []    ) {}    public function handle(): void    {        $response = Http::timeout(10)            ->retry(3, 1000)            ->post($this->webhookUrl, array_merge([                'batch_id' => $this->batchId,                'status' => 'completed',                'timestamp' => now()->toISOString(),            ], $this->payload));        if (!$response->ok()) {            Log::warning('Batch webhook failed', [                'batch_id' => $this->batchId,                'url' => $this->webhookUrl,                'status' => $response->status(),                'body' => $response->body(),            ]);                        // Re-queue for retry            $this->release(30);        }    }}// In your batch callback:Bus::batch($jobs)    ->then(function (Batch $batch) {        // Dispatch webhook for each registered endpoint        $webhooks = BatchWebhook::where('event', 'batch.completed')            ->where('active', true)            ->get();                    foreach ($webhooks as $webhook) {            DispatchBatchCompletionWebhook::dispatch(                $batch->id,                $webhook->url,                $webhook->payload ?? []            );        }    })    ->dispatch();

Database Batch Tracking Model

Persist batch metadata for audit trails and dashboard reporting:

<?phpnamespace App\Models;use Illuminate\Database\Eloquent\Model;use Illuminate\Database\Eloquent\Relations\BelongsTo;use Illuminate\Database\Eloquent\Casts\Attribute;class BatchRecord extends Model{    protected $table = 'batch_records';        protected $fillable = [        'batch_id',        'name',        'user_id',        'total_jobs',        'status', // pending, processing, completed, failed, cancelled        'progress',        'failed_job_ids',        'metadata',        'started_at',        'completed_at',    ];    protected $casts = [        'failed_job_ids' => 'array',        'metadata' => 'array',        'started_at' => 'datetime',        'completed_at' => 'datetime',        'progress' => 'decimal:2',    ];    public function user(): BelongsTo    {        return $this->belongsTo(User::class);    }    protected function status(): Attribute    {        return Attribute::make(            get: fn($value) => $value,            set: fn($value) => in_array($value, ['pending', 'processing', 'completed', 'failed', 'cancelled'])                 ? $value                 : 'pending'        );    }    public static function createFromBatch(\Illuminate\Bus\Batch $batch, ?int $userId = null): self    {        return self::create([            'batch_id' => $batch->id,            'name' => $batch->name,            'user_id' => $userId ?? auth()->id(),            'total_jobs' => $batch->totalJobs,            'status' => 'processing',            'progress' => 0,            'started_at' => now(),        ]);    }    public function updateFromBatch(\Illuminate\Bus\Batch $batch): void    {        $this->update([            'progress' => $batch->progress(),            'failed_job_ids' => $batch->failedJobIds ?? [],            'status' => match (true) {                $batch->cancelled() => 'cancelled',                $batch->pendingJobs === 0 && $batch->failedJobs === 0 => 'completed',                $batch->pendingJobs === 0 && $batch->failedJobs > 0 => 'failed',                default => 'processing',            },            'completed_at' => $batch->pendingJobs === 0 ? now() : null,        ]);    }}

Comparison Table: Batching vs Individual Dispatch

h
AspectIndividual Job Dispatch
Progress TrackingManual (custom tracking table, events)Built-in via Batch::progress(), pendingJobs, failedJobs
Completion DetectionComplex (count completed vs total, race conditions)Automatic via then() callback
Failure HandlingPer-job failed() method onlyBatch-level catch() + per-job failed()
CancellationManual (flag in DB, check in each job)Batch::cancel() + $job->batch()->cancelled()
Callback ChainingManual job chaining (Job::chain())Built-in then()/catch()/finally()
AtomicityNone - jobs independentLogical grouping, but jobs still execute independently
Memory/OverheadLower per-job overheadExtra batch record + callback jobs
Monitoring (Horizon)Individual job metricsBatch dashboard with aggregate metrics
Best ForIndependent, fire-and-forget tasksRelated jobs needing collective tracking/callbacks

Best Practices

1. Always Use the Batchable Trait

Jobs without Batchable won't have access to $this->batch(), preventing cancellation checks and batch-aware logic. Make it a habit to include the trait on all queueable jobs, even if not immediately used in batches.

2. Name Your Batches

Use ->name('Descriptive Name') when dispatching. Names appear in Horizon, database records, and logs, making debugging significantly easier. Include context like entity IDs: "Order Export - Order #12345".

3. Handle Partial Failures Gracefully

Decide whether a single failure should stop the batch (default) or be tolerated (allowFailures()). For notification sends, use allowFailures(). For financial transactions, use default behavior. Document this decision in code comments.

4. Implement Idempotency in Jobs

Batch jobs can be retried. Ensure jobs are idempotent: use updateOrCreate, check existing records, or use database constraints. This prevents duplicate side effects when workers retry failed jobs.

5. Keep Callbacks Lightweight

Batch callbacks (then, catch, finally) run as queued jobs. Avoid heavy computation, external API calls, or long-running operations in callbacks. Dispatch new jobs for post-batch work instead.

6. Store Batch IDs for User-Facing Features

If users initiate batch operations (exports, imports, bulk actions), save the batch ID to a database record linked to the user. This enables progress UIs, cancellation buttons, and result retrieval.

7. Use allowFailures() for Independent Jobs

When jobs are truly independent (e.g., sending emails to different users), allowFailures() ensures one bad email address doesn't trigger the failure callback or block other deliveries.

8. Test Batch Callbacks Thoroughly

Write feature tests that verify callback execution, including edge cases: all success, partial failure, total failure, and cancellation. Use Queue::fake() and Bus::fake() for assertion.

Common Mistakes

1. Forgetting the Batchable Trait

Problem: Jobs don't implement Batchable, so $this->batch() returns null and cancellation checks fail silently.Fix: Add use Batchable; to every job class. Create a base job class with the trait to enforce consistency.

2. Assuming Callbacks Run Synchronously

Problem: Developers expect then() to execute immediately after the last job, within the same request/process.Fix: Understand callbacks are queued jobs. Don't rely on callback side effects being visible immediately after dispatch(). Use polling or websockets for real-time UI updates.

3. Not Handling Batch Cancellation in Jobs

Problem: Long-running jobs continue processing after Batch::cancel() is called, wasting resources.Fix: Check $this->batch()?->cancelled() at logical breakpoints in handle() and return early.

4. Exceeding Callback Serialization Limits

Problem: Closures capturing large objects or model instances fail serialization when stored in the batch's options column.Fix: Capture only primitives (IDs, strings) in callbacks. Fetch models inside the callback using IDs.

5. Ignoring Failed Job IDs

Problem: After a batch fails, you can't identify which specific jobs failed.Fix: Access $batch->failedJobIds in the catch callback. Query the failed_jobs table for details.

6. Creating Massive Batches Without Chunking

Problem: Dispatching 50,000+ jobs in a single batch causes memory issues and long queue insertion times.Fix: Split into multiple batches of 1,000-5,000 jobs. Use a parent job to orchestrate child batches.

7. Not Monitoring Batch Stalls

Problem: A batch appears stuck because workers aren't processing, but no alert fires.Fix: Implement a scheduled command that checks for batches with pendingJobs > 0 and updated_at > 10 minutes ago.

Performance Tips

Batch Size Optimization

Optimal batch size depends on job complexity and queue driver. For database queues, batches of 1,000-5,000 jobs balance insertion speed and memory. For Redis, you can go higher (10,000+). Test with your specific workload.

Use Redis for High-Throughput Batching

The database driver writes a row per job plus batch updates. Redis stores batch data in hash structures with atomic operations, significantly faster for high-volume batching. Configure queue.batching.redis in config/queue.php.

Disable Unnecessary Logging

Default queue logging creates entries for every job. For large batches, this floods logs. Configure logging levels in config/logging.php or use a custom queue logger that samples or aggregates batch job logs.

Lazy Collections for Job Generation

When creating jobs from large datasets, use lazy collections to avoid loading all models into memory:

<?phpuse App\Models\User;use App\Jobs\SendNotification;use Illuminate\Support\Facades\Bus;use Illuminate\Bus\Batch;use Illuminate\Support\LazyCollection;$jobs = User::query()    ->where('notifications_enabled', true)    ->lazyById(500) // Chunk by 500    ->map(fn($user) => new SendNotification($user->id));Bus::batch($jobs)    ->name('Bulk Notifications')    ->dispatch();

Batch Progress Caching

For high-frequency progress polling, cache batch progress in Redis with a short TTL to reduce database queries:

<?php// In a job's handle method, after processing:if ($this->batch()) {    Cache::put("batch_progress_{$this->batch()->id}", [        'progress' => $this->batch()->progress(),        'updated_at' => now(),    ], 30); // 30 second TTL}// In progress API endpoint:$cached = Cache::get("batch_progress_{$batchId}");if ($cached) {    return response()->json($cached);}// Fallback to Bus::findBatch()

Horizon Metrics for Batch Monitoring

If using Laravel Horizon, enable batch metrics in config/horizon.php. Horizon provides batch-specific dashboards showing throughput, runtime, and failure rates per batch.

Security Considerations

Batch ID Exposure

Batch UUIDs are not secret — they're visible in queue payloads, Horizon UI, and API responses. Don't rely on UUID obscurity for authorization. Always implement proper authorization checks in batch progress/management endpoints.

Callback Injection Risk

Never serialize user-controlled data into batch callbacks. Closure serialization can execute arbitrary code if deserialized maliciously. Only capture primitive values (IDs, strings) in callbacks.

Resource Exhaustion via Large Batches

Malicious or buggy code could dispatch massive batches (millions of jobs), exhausting queue storage and worker capacity. Implement application-level limits: maximum jobs per batch, maximum batches per user per hour.

Sensitive Data in Batch Metadata

The job_batches table stores callback data in the options column. Don't put secrets, PII, or tokens in callbacks. If you need to pass sensitive data, store a reference ID and fetch securely in the callback.

Queue Worker Permissions

Workers processing batch jobs run with the same permissions as your application. Ensure queue workers use a dedicated database user with minimal privileges. Never run workers as root.

Deployment Notes

Queue Worker Configuration

For production batch processing, run dedicated workers for batch queues:

# Supervisor config example[program:laravel-batch-worker]process_name=%(program_name)s_%(process_num)02dcommand=php /var/www/artisan queue:work redis --queue=batches,default --sleep=3 --tries=3 --max-time=3600autostart=trueautorestart=trueuser=www-datanumprocs=4redirect_stderr=truestdout_logfile=/var/www/storage/logs/batch-worker.log

Batch Table Maintenance

The job_batches table grows over time. Implement a cleanup policy:

<?php// In a scheduled command (daily)use Illuminate\Support\Facades\DB;use Carbon\Carbon;DB::table('job_batches')    ->where('created_at', '<', Carbon::now()->subDays(30))    ->where('pending_jobs', 0)    ->delete();// Also clean failed_jobs tableDB::table('failed_jobs')    ->where('failed_at', '<', Carbon::now()->subDays(30))    ->delete();

Horizon Configuration for Batches

In config/horizon.php, ensure your environments have appropriate batch settings:

<?php// config/horizon.php'environments' => [    'production' => [        'supervisor-batches' => [            'connection' => 'redis',            'queue' => ['batches', 'high', 'default'],            'balance' => 'auto',            'maxProcesses' => 10,            'maxTime' => 3600,            'maxJobs' => 1000,            'memory' => 256,            'tries' => 3,            'nice' => 0,        ],    ],],

Zero-Downtime Deployments

During deployment, in-flight batches continue processing. However, callback code changes won't affect already-dispatched batches (callbacks are serialized at dispatch time). For critical callback changes, wait for pending batches to complete or cancel them before deploying.

Debugging Tips

Inspect Batch State in Tinker

php artisan tinker>> $batch = Bus::findBatch('uuid-here');>> $batch->progress()>> $batch->failedJobIds>> $batch->cancelled()

Debug Failed Batch Jobs

# List failed jobs for a batchphp artisan queue:failed --batch=uuid-here# Retry all failed jobs in a batchphp artisan queue:retry --batch=uuid-here

Log Batch Lifecycle Events

Create a batch observer for centralized logging:

<?phpnamespace App\Observers;use Illuminate\Bus\Batch;use Illuminate\Bus\Events\BatchCreated;use Illuminate\Bus\Events\BatchCompleted;use Illuminate\Bus\Events\BatchFailed;use Illuminate\Support\Facades\Log;class BatchObserver{    public function created(BatchCreated $event): void    {        Log::info('Batch created', [            'batch_id' => $event->batch->id,            'name' => $event->batch->name,            'total_jobs' => $event->batch->totalJobs,        ]);    }    public function completed(BatchCompleted $event): void    {        Log::info('Batch completed', [            'batch_id' => $event->batch->id,            'duration_seconds' => $event->batch->created_at?->diffInSeconds(now()),            'failed_jobs' => $event->batch->failedJobs,        ]);    }    public function failed(BatchFailed $event): void    {        Log::error('Batch failed', [            'batch_id' => $event->batch->id,            'exception' => $event->exception->getMessage(),            'failed_job_ids' => $event->batch->failedJobIds,        ]);    }}// Register in AppServiceProvider:Event::listen([    BatchCreated::class => [BatchObserver::class, 'created'],    BatchCompleted::class => [BatchObserver::class, 'completed'],    BatchFailed::class => [BatchObserver::class, 'failed'],]);

Test Batch Callbacks with Queue Fake

<?phpuse Illuminate\Support\Facades\Bus;use Illuminate\Support\Facades\Queue;use Tests\TestCase;class BatchTest extends TestCase{    public function test_batch_completion_callback_dispatched()    {        Queue::fake();        Bus::fake();        $job = new ProcessData(1);                Bus::batch([$job])            ->then(fn($batch) => Log::info('Done'))            ->dispatch();        // Assert batch was created        Bus::assertBatched(function ($batch) {            return $batch->name === 'test-batch';        });        // Simulate job completion        $batch = Bus::assertBatched()[0];                // Process the job manually for testing        $job->handle();                // Assert callback was queued        Queue::assertPushed(/* callback job class */);    }}

FAQ

Can I add jobs to an existing batch after dispatch?

No. Once a batch is dispatched, its job list is fixed. You cannot add jobs to an existing batch. If you need dynamic job addition, create a new batch or use a parent job that dispatches child batches.

What happens if a batch callback fails?

Batch callbacks (then, catch, finally) are themselves queued jobs. If a callback fails, it follows standard queue retry logic (respects tries, backoff, etc.). After all retries exhausted, it goes to the failed_jobs. The batch itself is considered complete regardless of callback success.

Can I use batches with queue chaining (Job::chain())?

Yes, but they serve different purposes. Chains execute sequentially (job B waits for job A). Batches execute in parallel. You can include chained jobs within a batch, or chain batches by dispatching a new batch in a then() callback.

Does allowFailures() prevent jobs from going to the failed_jobs table?

No. allowFailures() only prevents the catch() callback from triggering. Failed jobs still go to the failed_jobs table and can be retried normally.

How do I get the batch ID inside a job?

Use $this->batch()?->id within any job that uses the Batchable trait. Returns null if the job wasn't dispatched as part of a batch.

Can batches work with the sync queue driver?

Yes, but synchronously. All jobs execute immediately in the current process, and callbacks fire before dispatch() returns. Useful for testing but not for production background processing.

What's the maximum number of jobs in a single batch?

No hard limit in Laravel, but practical limits exist: database queue insertion time, memory for callback serialization, and batch record size. Recommend 1,000-10,000 jobs per batch. For larger workloads, split into multiple batches.

How do I retry only failed jobs in a batch?

Use php artisan queue:retry --batch=batch-uuid to retry all failed jobs in that batch. Or programmatically: Bus::findBatch($id)?->retry() (available in Laravel 10+).

Conclusion

Laravel's job batching with Bus::batch() is a game-changer for handling bulk operations elegantly. It transforms what used to require custom tracking tables, complex event listeners, and fragile completion detection into a clean, first-class API with progress tracking, callbacks, and cancellation built in.

We've covered the full spectrum: from basic batch dispatching to advanced patterns like chunked CSV imports, multi-tenant report generation, rate-limited batch middleware, and webhook notifications. The key takeaways:

  • Always use the Batchable trait on jobs
  • Name batches for observability
  • Choose allowFailures() deliberately based on job independence
  • Keep callbacks lightweight — dispatch follow-up jobs instead
  • Monitor batch progress via API or Horizon
  • Implement cleanup policies for batch records

Start small: identify one bulk operation in your application (user notifications, data exports, report generation) and refactor it to use batching. You'll immediately gain visibility, reliability, and maintainability.

Ready to level up your Laravel queue game? Check out our guides on Laravel Horizon Dashboard Setup for monitoring batches at scale, Queue Worker Scaling for performance tuning, and Queue Retry Strategies for bulletproof failure handling.