A consumer wants to create fifty licenses in one request. The naive version loops:
// Bad: fifty provider calls inside one request
$created = [];
foreach ($request->validated('licenses') as $item) {
$created[] = Licenses::create(
name: $item['name'],
domain: $item['domain'],
);
}
return LicenseResource::collection($created);
Each call takes a second or so. The request runs past the web server’s timeout around item thirty, the consumer gets a 504, and nobody knows which of the fifty succeeded. Retrying creates duplicates of the first thirty.
Stop doing the work inside the request. Accept the batch, queue one job per item, and answer at once. A batch is a resource of its own, so it gets a resourceful route and a controller of its own, and the controller starts by saying who may call it:
// routes/api.php, inside the auth:sanctum group
Route::apiResource(
'license-batches',
LicenseBatchController::class,
)->only(['store', 'show']);
// app/Http/Controllers/LicenseBatchController.php
#[Middleware('ability:licenses:write')]
class LicenseBatchController
{
public function store(
StoreLicenseBatchRequest $request,
): JsonResponse {
$consumer = $request->user();
$jobs = [];
foreach ($request->validated('licenses') as $item) {
$jobs[] = new CreateLicense($item, $consumer);
}
$batch = Bus::batch($jobs)
->name("consumer:{$consumer->id}")
->allowFailures()
->dispatch();
return response()->json(
['data' => ['id' => $batch->id]],
Response::HTTP_ACCEPTED,
);
}
}
The ability line is not optional, and it is the mistake Chapter 4 warned about. This is a new controller. Without that attribute, a read-only token could create a hundred licenses per request through the one controller nobody thought to close.
StoreLicenseBatchRequest is the request from Chapter 2, with its ceiling of a hundred items. It also checks that the batch fits inside what remains of the consumer’s license_limit:
// app/Http/Requests/StoreLicenseBatchRequest.php
public function after(): array
{
return [function (Validator $validator): void {
if ($validator->errors()->isNotEmpty()) {
return;
}
$room = $this->user()->license_limit
- Licenses::all()->count();
if ($this->collect('licenses')->count() > $room) {
$validator->errors()->add(
'licenses',
__('validation.license_room'),
);
}
}];
}
An after() hook runs even when the field rules have failed, so it returns early in that case. A request that is already invalid shouldn’t also cost a call to the provider. (Validator here is the validator instance Laravel passes in, not the facade Chapter 3 used.)
This is validation and not Chapter 7’s exception, by Chapter 2’s test: the consumer can fix it by sending fewer items.
Each job checks again before it calls the provider, because two batches can be accepted at the same moment and only the job sees the state at the time it runs. CreateLicense uses the Batchable trait, carries its consumer as DeleteLicense does, and returns at once if that consumer has been removed. Then it repeats the two checks store makes:
// app/Jobs/CreateLicense.php, in createWithinLimit()
$driver = StatamicDriver::for($this->consumer);
$held = $driver->all();
$domain = $this->item['domain'];
if ($held->pluck('domains')->flatten()->contains($domain)) {
return; // an earlier attempt already made it
}
if ($held->count() >= $this->consumer->license_limit) {
$this->fail(new LicenseLimitReached);
return;
}
$driver->create(name: $this->item['name'], domain: $domain);
It is the same pair of checks, written as collection calls because the job has no helper of its own. With several workers, jobs of one batch run side by side, and two of them can read the same count. So handle() runs that method under a lock, the one Chapter 19 also puts around store:
// app/Jobs/CreateLicense.php, in handle()
Cache::lock("license-create:{$this->consumer->id}", 120)
->block(30, fn () => $this->createWithinLimit());
allowFailures() is a choice. Here the items are independent, so one rejected domain shouldn’t stop the other forty-nine. When the items depend on each other, leave it off and let the first failure cancel the rest, as the provisioning batch in Chapter 10 does.
A 202 with nothing to follow up on is half an answer. Give the consumer a place to ask how it went:
// app/Http/Controllers/LicenseBatchController.php
public function show(
Request $request,
string $licenseBatch,
): JsonResponse {
$batch = Bus::findBatch($licenseBatch);
$owner = "consumer:{$request->user()->id}";
abort_unless($batch?->name === $owner, 404);
return response()->json(['data' => [
'total' => $batch->totalJobs,
'pending' => $batch->pendingJobs,
'failed' => $batch->failedJobs,
'finished' => $batch->finished(),
]]);
}
The abort_unless is the authorization. A batch ID is a handle to someone else’s work unless you check whose it is, and answering 404 and not 403 avoids confirming that the batch exists.
Everything in Chapter 10 about running twice applies to each CreateLicense job. And when the batch is done, its finally() callback can fire the webhook you just built, so a consumer who subscribed doesn’t have to poll at all. Use finally() and not then(): with allowFailures(), then() runs only if every job succeeded.