Going Further

Job Batching

When a user action fans out into many independent jobs and the UI needs a live progress bar plus a Cancel-All button — how the project wraps Laravel's native batching through the `Core\JobBatch` sub-domain, and the things you must not build.

Some operations fan out: a multi-create form that submits forty rows, a bulk import that reads a spreadsheet, a multi-file export. Each row is independent work, and the user wants to watch it finish — a progress bar, a count of what succeeded and failed, and a Cancel-All button. That shape is what Laravel's job batching exists for, and the project wraps it through the Core\JobBatch feature group.

The rule for this chapter is mostly about restraint. Native Bus::batch() already tracks totals, processed counts, failures, progress percentage, and cancellation. The mistake is re-implementing any of that — a custom *_batches table, a Cache entry for state, an Eloquent accessor that recomputes progress(). Don't. Every count and status comes from the native Illuminate\Bus\Batch object, and nothing is cached or shadowed.

The shape

Producer.store()  →  Producer Action  →  Bus::batch([Job, ...])
                                              ->withOption('user_id',   $uid)
                                              ->withOption('user_type', $type)
                                              ->dispatch()
                                          → return $batch->id (JSON)

Frontend polls   ──►  JobBatchController.show($id)       ──►  Bus::findBatch($id) + failed_jobs lookup
Frontend cancels ──►  JobBatchController.cancelAll($id)  ──►  $batch->cancel()

Three rules hold the pattern together:

  1. One Job class per unit of work — never a closure inside Bus::batch(). A real Job class is easier to retry, monitor on the queue, and unit-test.
  2. Ownership lives in withOption('user_id', …) — it is written to job_batches.options and survives worker boundaries. A session-stored batch_id does not.
  3. Every count, percentage, and status comes from the native Batch objecttotalJobs, processedJobs(), failedJobs, progress(), finished(), cancelled(), cancel(). Nothing is recomputed in an accessor; nothing is cached.

Why native only

The native Batch object exposes everything a batch-level UI needs:

NeedNative API
Total$batch->totalJobs
Processed$batch->processedJobs()
Failed (count)$batch->failedJobs
Failed (UUIDs)$batch->failedJobIds → join failed_jobs.uuid for the messages
Progress %$batch->progress()
Finished?$batch->finished()
Cancelled?$batch->cancelled()
Cancel the batch$batch->cancel()
Ownership / metadata$batch->options (from withOption())

What native batching does not give you is per-row state (pending | processing | completed | failed) or per-row cancellation.

Don't build per-row state. Adding a Cache entry or a custom table to track each job individually is re-creating infrastructure the framework deliberately doesn't provide, and it is almost never worth the complexity for a multi-create screen. Show the aggregate progress and a Cancel-All button — that is the whole feature.

Where the code lives

JobBatch is a feature group inside the Core domain, not a separate domain. The files sit in Core's existing buckets, under a JobBatch/ sub-folder where there is more than one file (the same convention as Core/Actions/StateMachine/ — see State machine).

src/Domain/Core/
├── Actions/
│   └── JobBatch/
│       ├── GetJobBatch.php             // Bus::findBatch($id)
│       ├── CancelJobBatch.php          // $batch->cancel()
│       └── GetFirstFailureMessage.php  // failed_jobs.exception lookup
├── Contracts/
│   └── JobBatchContract.php
├── Providers/
│   └── JobBatchServiceProvider.php     // registered in bootstrap/providers.php
└── Services/
    └── JobBatchService.php             // pass-through over the three Actions

The Contract has three methods and no more:

interface JobBatchContract
{
    public function getJobBatch(string $id): ?Batch;                   // Bus::findBatch wrapper
    public function cancel(Batch $batch): void;                        // delegates to $batch->cancel()
    public function firstFailureMessage(array $failedJobIds): ?string; // failed_jobs.exception, first line
}

There is no Eloquent JobBatch model in the read path, no listing Action, no FilterJobBatchData — there is no admin listing page. Every batch is reached by ID from the producer's own polling UI. If you ever need a list, add those pieces back at that point, not before.

The producer side

The producer Action stays slim: build the array of Job instances, dispatch with ownership options, return the batch ID.

// Producer Action — build the Job array, dispatch with ownership options.
public function handle(): string
{
    $jobs = $rows
        ->map(fn ($row) => new CreateOrdersFromExcelRow($row, $defaults))
        ->all();

    $batch = Bus::batch($jobs)
        ->name('create-orders-excel')
        ->withOption('user_id',   $this->data->created_by_id)
        ->withOption('user_type', $this->data->created_by_type)
        ->dispatch();

    return $batch->id;
}

Each Job guards against a cancelled batch and otherwise does its one unit of work. It writes no batch state of its own.

// Producer Job — guard cancellation, then work.
public function handle(): void
{
    if ($this->batch()?->cancelled()) {
        return;
    }

    CreateOrdersExcel::processRow($this->row, $this->defaults);
}

The cancellation guard matters: cancelling a batch stops new jobs from being released, but jobs already on the queue still run. Without the $this->batch()?->cancelled() check at the top of handle(), every remaining job keeps writing its side effects after the user hit Cancel.

The batch controller

Polling and cancellation are owned by one shared controller — JobBatchController — never re-implemented on each producer.

MethodHTTPReturnsJob
showGET /cp/job_batches/{id}JSONstatus + counters + first error, for the polling UI
cancelAllPOST /cp/job_batches/{id}/cancelJSON$batch->cancel()

The route group is excluded from role/permission scaffolding in config/roles.php (cp.job_batches.* lives in cp.exclude). Ownership is enforced per request from job_batches.options, not from the session:

public function show(string $batchId): JsonResponse
{
    $batch = $this->findOwnedBatch($batchId);

    return response()->json([
        'status'    => $this->statusOf($batch),
        'total'     => $batch->totalJobs,
        'processed' => $batch->processedJobs(),
        'failed'    => $batch->failedJobs,
        'progress'  => $batch->progress(),
        'error'     => $this->firstFailureMessage($batch->failedJobIds),
    ]);
}

private function findOwnedBatch(string $batchId): Batch
{
    $batch = $this->jobBatchContract->getJobBatch($batchId);
    abort_if($batch === null, 404);
    abort_unless(
        ($batch->options['user_id']   ?? null) === (string) auth('employees')->id()
            && ($batch->options['user_type'] ?? null) === Employee::class,
        403,
    );

    return $batch;
}

private function statusOf(Batch $batch): string
{
    return match (true) {
        $batch->cancelled() => 'cancelled',
        $batch->finished()  => $batch->failedJobs > 0 ? 'failed' : 'completed',
        default             => 'processing',
    };
}

The frontend

The producer view polls the two endpoints — it does not talk to the queue directly:

BATCH_URL_BASE = "{{ url('cp/job_batches') }}"

GET  BATCH_URL_BASE + '/' + id              → poll   { status, total, processed, failed, progress, error }
POST BATCH_URL_BASE + '/' + id + '/cancel'  → cancel-all

The UI shows a big percentage, the native progress bar, three counters (Created = processed − failed, Failed, Total), and a single Cancel-All button. On finish it swaps in a "Go to orders" call-to-action. There is no per-row list, no per-row cancel button, and no per-row status badge — that is the per-row state the pattern deliberately doesn't track.

Adding a new batched feature

  1. The producer Action accepts a DTO and builds new YourJob($payload, $defaults) per unit of work.
  2. The Job's handle() early-returns on $this->batch()?->cancelled(), then does the work.
  3. Dispatch with Bus::batch($jobs)->withOption('user_id', …)->withOption('user_type', …)->dispatch().
  4. The producer Action returns $batch->id; the producer controller's store() returns it as { batch_id } JSON.
  5. Reuse JobBatchController for polling and cancel-all — do not add status / cancel endpoints to the producer controller.

When not to use it

  • One job, fire-and-forgetdispatch(new Job(...)). A batch is pure overhead for a single job.
  • Strict sequential dependencyBus::chain([...]). Batches run their jobs in parallel; a chain runs them in order.
  • No UI feedback needed — if nothing polls the batch, you are paying for a job_batches row no one reads.
  • Cross-module communication — events and queued jobs as the sanctioned ways one domain triggers work in another.
  • Actions — the producer Action and the per-row work both follow the standard Action template.
  • State machine — the other Core feature group that uses the same Actions/{Feature}/ sub-folder convention.
  • Anti-patterns — the project's review vocabulary; the "don't shadow native state" rule here is the same instinct.