Skip to content

[12.x] Add DeferredBatch for deferred batch construction in job chains#58905

Open
heffaklump90 wants to merge 1 commit intolaravel:12.xfrom
heffaklump90:feature/deferred-batch
Open

[12.x] Add DeferredBatch for deferred batch construction in job chains#58905
heffaklump90 wants to merge 1 commit intolaravel:12.xfrom
heffaklump90:feature/deferred-batch

Conversation

@heffaklump90
Copy link

Summary

  • Adds DeferredBatch class — a sibling to ChainedBatch that defers batch construction to execution time
  • Adds Dispatcher::deferredBatch() convenience method
  • Includes 14 tests covering closures, invokable classes, serialization, chain propagation, and edge cases

Motivation

ChainedBatch requires all batch jobs to be known at chain construction time. In many real-world scenarios, a batch's contents depend on data created by earlier jobs in the chain. For example, an import job creates buildings, then a calculation batch needs to process those buildings. Since ChainedBatch eagerly captures jobs in its constructor, the batch is empty when built before the chain runs.

DeferredBatch solves this by accepting a callable (closure or invokable class) that is invoked at execution time and must return a PendingBatch or null to skip.

Usage

use Illuminate\Bus\DeferredBatch;
use Illuminate\Support\Facades\Bus;

Bus::chain([
    new ImportBuildingsJob($projectId),

    // Batch is built at runtime, after ImportBuildingsJob has run
    new DeferredBatch(function () use ($projectId) {
        $buildings = Building::where('project_id', $projectId)->get();

        if ($buildings->isEmpty()) {
            return null; // Skip, chain continues
        }

        return Bus::batch(
            $buildings->map(fn ($b) => new CalculateBuildingJob($b))
        )->name('calculate-buildings');
    }),

    new FinalizeProjectJob($projectId),
])->dispatch();

Design

  • Reuses ChainedBatch's proven attachRemainderOfChainToEndOfBatch() pattern for chain continuation via finally callback
  • Propagates chainCatchCallbacks to the batch (matching ChainedBatch behavior)
  • Cancelled batches do not resume the chain (matching ChainedBatch behavior)
  • Returning null from the builder skips the batch — chain continues normally via dispatchNextJobInChain()
  • Closures are wrapped in SerializableClosure for queue serialization; invokable classes work as-is

Test plan

  • Closure and invokable class builders accepted
  • Builder returning null continues chain without interruption
  • Invalid return type throws InvalidArgumentException
  • Batch dispatching from builder works correctly
  • Serialization round-trip with closures and invokable classes
  • Chain preservation on null return (for queue worker)
  • Queue/connection propagation through chain
  • Chain remainder attachment to batch via finally callback
  • Chain catch callback propagation to batch
  • Dispatcher::deferredBatch() helper
  • ShouldQueue interface implementation
  • Queue/connection settings on the job itself

🤖 Generated with Claude Code

Add `DeferredBatch`, a sibling to `ChainedBatch` that defers batch
construction to execution time. This allows batch contents to depend on
data created by earlier jobs in the chain.

`ChainedBatch` requires all batch jobs to be known at chain construction
time. In many real-world scenarios, a batch's contents depend on data
created by earlier jobs (e.g., an import job creates records, then a
calculation batch processes those records). `DeferredBatch` solves this
by accepting a callable that is invoked at execution time and must return
a `PendingBatch` or `null` to skip.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@dxnter
Copy link
Contributor

dxnter commented Feb 19, 2026

I'm not really sure if this necessitates an entirely new class. I think a more targeted improvement would be adding a dispatchBatchInChain method to the Queueable trait (alongside dispatchNextJobInChain, prependToChain, and appendToChain). This would let any job, including inline closure jobs, hand off chain continuation to a batch. Your example would become:

Bus::chain([
    new ImportBuildingsJob($projectId),

    CallQueuedClosure::create(function ($job) use ($projectId) {
        $buildings = Building::where('project_id', $projectId)->get();

        if ($buildings->isEmpty()) {
            return;
        }

        $job->dispatchBatchInChain(
            Bus::batch(
                $buildings->map(fn ($b) => new CalculateBuildingJob($b))
            )->name('calculate-buildings')
        );
    }),

    new FinalizeProjectJob($projectId),
])->dispatch();

This would also work from any regular job class, and ChainedBatch itself could use it internally instead of having its own copy of the logic.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

Comments